diff --git a/bin/zkOfflineAudit.cmd b/bin/zkOfflineAudit.cmd new file mode 100644 index 00000000000..b8adc0e3d3f --- /dev/null +++ b/bin/zkOfflineAudit.cmd @@ -0,0 +1,23 @@ +@echo off +REM Licensed to the Apache Software Foundation (ASF) under one or more +REM contributor license agreements. See the NOTICE file distributed with +REM this work for additional information regarding copyright ownership. +REM The ASF licenses this file to You under the Apache License, Version 2.0 +REM (the "License"); you may not use this file except in compliance with +REM the License. You may obtain a copy of the License at +REM +REM http://www.apache.org/licenses/LICENSE-2.0 +REM +REM Unless required by applicable law or agreed to in writing, software +REM distributed under the License is distributed on an "AS IS" BASIS, +REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +REM See the License for the specific language governing permissions and +REM limitations under the License. + +setlocal +call "%~dp0zkEnv.cmd" + +set ZOOMAIN=org.apache.zookeeper.server.OfflineAuditExporter +call %JAVA% -cp "%CLASSPATH%" %JVMFLAGS% %ZOOMAIN% %* + +endlocal & exit /b %ERRORLEVEL% diff --git a/bin/zkOfflineAudit.sh b/bin/zkOfflineAudit.sh new file mode 100755 index 00000000000..94625b018b3 --- /dev/null +++ b/bin/zkOfflineAudit.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ZOOBIN="${BASH_SOURCE-$0}" +ZOOBIN="$(dirname "${ZOOBIN}")" +ZOOBINDIR="$(cd "${ZOOBIN}"; pwd)" + +if [ -e "$ZOOBIN/../libexec/zkEnv.sh" ]; then + . "$ZOOBINDIR"/../libexec/zkEnv.sh +else + . "$ZOOBINDIR"/zkEnv.sh +fi + +"$JAVA" -cp "$CLASSPATH" $JVMFLAGS \ + org.apache.zookeeper.server.OfflineAuditExporter "$@" diff --git a/bin/zkSnapshotComparer.cmd b/bin/zkSnapshotComparer.cmd new file mode 100644 index 00000000000..9701d00db7b --- /dev/null +++ b/bin/zkSnapshotComparer.cmd @@ -0,0 +1,23 @@ +@echo off +REM Licensed to the Apache Software Foundation (ASF) under one or more +REM contributor license agreements. See the NOTICE file distributed with +REM this work for additional information regarding copyright ownership. +REM The ASF licenses this file to You under the Apache License, Version 2.0 +REM (the "License"); you may not use this file except in compliance with +REM the License. You may obtain a copy of the License at +REM +REM http://www.apache.org/licenses/LICENSE-2.0 +REM +REM Unless required by applicable law or agreed to in writing, software +REM distributed under the License is distributed on an "AS IS" BASIS, +REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +REM See the License for the specific language governing permissions and +REM limitations under the License. + +setlocal +call "%~dp0zkEnv.cmd" + +set ZOOMAIN=org.apache.zookeeper.server.SnapshotComparer +call %JAVA% -cp "%CLASSPATH%" %ZOOMAIN% %* + +endlocal & exit /b %ERRORLEVEL% diff --git a/bin/zkSnapshotComparer.sh b/bin/zkSnapshotComparer.sh new file mode 100755 index 00000000000..0a2283429f7 --- /dev/null +++ b/bin/zkSnapshotComparer.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ZOOBIN="${BASH_SOURCE-$0}" +ZOOBIN="$(dirname "${ZOOBIN}")" +ZOOBINDIR="$(cd "${ZOOBIN}"; pwd)" + +if [ -e "$ZOOBIN/../libexec/zkEnv.sh" ]; then + . "$ZOOBINDIR"/../libexec/zkEnv.sh +else + . "$ZOOBINDIR"/zkEnv.sh +fi + +"$JAVA" -cp "$CLASSPATH" $JVMFLAGS \ + org.apache.zookeeper.server.SnapshotComparer "$@" diff --git a/bin/zkSnapshotRecursiveSummaryToolkit.cmd b/bin/zkSnapshotRecursiveSummaryToolkit.cmd new file mode 100644 index 00000000000..850704a3719 --- /dev/null +++ b/bin/zkSnapshotRecursiveSummaryToolkit.cmd @@ -0,0 +1,23 @@ +@echo off +REM Licensed to the Apache Software Foundation (ASF) under one or more +REM contributor license agreements. See the NOTICE file distributed with +REM this work for additional information regarding copyright ownership. +REM The ASF licenses this file to You under the Apache License, Version 2.0 +REM (the "License"); you may not use this file except in compliance with +REM the License. You may obtain a copy of the License at +REM +REM http://www.apache.org/licenses/LICENSE-2.0 +REM +REM Unless required by applicable law or agreed to in writing, software +REM distributed under the License is distributed on an "AS IS" BASIS, +REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +REM See the License for the specific language governing permissions and +REM limitations under the License. + +setlocal +call "%~dp0zkEnv.cmd" + +set ZOOMAIN=org.apache.zookeeper.server.SnapshotRecursiveSummary +call %JAVA% -cp "%CLASSPATH%" %ZOOMAIN% %* + +endlocal & exit /b %ERRORLEVEL% diff --git a/bin/zkSnapshotRecursiveSummaryToolkit.sh b/bin/zkSnapshotRecursiveSummaryToolkit.sh new file mode 100755 index 00000000000..d43be95a84c --- /dev/null +++ b/bin/zkSnapshotRecursiveSummaryToolkit.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ZOOBIN="${BASH_SOURCE-$0}" +ZOOBIN="$(dirname "${ZOOBIN}")" +ZOOBINDIR="$(cd "${ZOOBIN}"; pwd)" + +if [ -e "$ZOOBIN/../libexec/zkEnv.sh" ]; then + . "$ZOOBINDIR"/../libexec/zkEnv.sh +else + . "$ZOOBINDIR"/zkEnv.sh +fi + +"$JAVA" -cp "$CLASSPATH" $JVMFLAGS \ + org.apache.zookeeper.server.SnapshotRecursiveSummary "$@" diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md index d015079d7dc..73ba3891938 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md @@ -1875,6 +1875,14 @@ options are used to configure the [AdminServer](#sc_adminserver). The URL for listing and issuing commands relative to the root URL. Defaults to "/commands". +* *zookeeper.quotaStats.allowedNamespaces* : + (Java system property) + A JSON array of exact namespace paths that may be queried by + [quota_stats](#sc_quota_stats). Defaults to `[]`, which denies every + namespace. Keep this list empty until the namespaces and operational + access have been explicitly approved. This is a metadata-disclosure + allowlist, not a quota limit or an authentication mechanism. + ### Metrics Providers **New in 3.6.0:** The following options are used to configure metrics. @@ -2326,6 +2334,12 @@ Available commands include: Reset all observer connection statistics. Companion command to *observers*. No new fields returned. +* *quota_stats* : + Read only the existing quota usage and limit metadata for one explicitly + allowlisted namespace. Requires a `path` parameter. See + [bounded quota telemetry](#sc_quota_stats) for configuration, schema, + unavailable results and sampling limitations. + * *ruok* : No-op command, check if the server is running. A response does not necessarily indicate that the @@ -2391,6 +2405,92 @@ Available commands include: Peers can be in one of these phases: ELECTION, DISCOVERY, SYNCHRONIZATION, BROADCAST. Returns fields "voting" and "zabstate". + + +##### Bounded quota telemetry + +`quota_stats?path=/example-quota` runs through the existing AdminServer +command URL, for example `/commands/quota_stats?path=/example-quota`. +`/example-quota` is illustrative, not an approved namespace. Use the existing +protected administrative access path; this command adds no authentication, +ACL policy, transport listener or four-letter command. + +The JVM property `zookeeper.quotaStats.allowedNamespaces` must contain one +complete JSON array of strings, for example `["/example-quota"]`. An unset +property means `[]` and rejects all requests. The command reads the property +once per request and validates the entire array before reading quota metadata; +an invalid later entry cannot be hidden by an earlier match. Non-array JSON, +non-string entries, malformed JSON, trailing JSON values, invalid paths and +unreadable configuration produce a normal command error rather than permissive +fallback. Duplicate valid entries have no additional effect. + +Both configured and requested paths must pass ZooKeeper's existing znode path +validation. Root `/`, `/zookeeper` and its descendants are excluded; +`/zookeeper-client` is not excluded by that reserved-path rule. Paths are +matched exactly, with no trimming, normalization, ancestor inheritance, +prefix matching or wildcard expansion. A literal `*` in a valid znode name is +just a character, not a pattern. A child namespace needs its own explicit +allowlist entry and its own quota metadata. Encode the `path` query parameter +normally when its characters require URL encoding. + +Invalid/missing paths, invalid configuration and disallowed paths return only +the usual `command` and non-null `error` fields. An uninitialized or stopped +server is rejected by the existing command-framework availability guard. +Successful requests, including unavailable quota samples, have this schema: + +| Field | Type | Meaning | +| --- | --- | --- | +| `command` | string | `quota_stats` | +| `error` | null | Request/configuration validation succeeded | +| `schema_version` | integer | `1` | +| `path` | string | The exact requested, allowlisted namespace | +| `count_used` | integer or null | Existing quota-stat node count, including the namespace itself | +| `bytes_used` | integer or null | Existing quota-stat total znode payload bytes, not path, ACL, packet or JVM-memory bytes | +| `count_limit` | integer or null | Existing quota count limit; native `-1` is reported as null | +| `bytes_limit` | integer or null | Existing quota byte limit; native `-1` is reported as null | +| `available` | boolean | Both exact metadata records are present and valid in this sample | +| `reason` | string or null | Unavailability reason below, or null when available | + +Counts use the native signed 32-bit integer range; byte values use signed +64-bit integers. Metadata must use the native `count=,bytes=` +format with exact field names/order and decimal digits. Usage must be +non-negative; limits must be at least `-1`. Null/empty payloads, malformed +records and numeric overflow are invalid, not zero usage. An available +sample can have one or both limits null (unset/unlimited in native quota +metadata); null is not approved infinite headroom. Zero limits remain zero. +The command computes no utilization ratios or headroom, and clients must +not divide by zero or treat unknown values as an approved budget. + +An unavailable sample sets **all four numeric fields to null** and uses one +of these reasons: + +| `reason` | Meaning | +| --- | --- | +| `namespace_missing` | The namespace does not exist when sampling starts, even if orphan quota metadata remains | +| `quota_missing` | Both exact quota metadata nodes are absent; an ancestor's quota is not substituted | +| `quota_incomplete` | Exactly one of the stat/limit nodes is absent | +| `invalid_quota_stats` | The stat record is null, malformed, negative or out of range | +| `invalid_quota_limits` | The limit record is null, malformed or out of range | +| `quota_changed` | Removal/replacement of the namespace or a sampled metadata node was detected during sampling | + +The only payloads read are the exact +`/zookeeper/quota/zookeeper_stats` and +`/zookeeper/quota/zookeeper_limits` records. Namespace existence and +node-identity checks use a bounded number of direct lookups. The command +does not walk the live subtree, search ancestor quotas, create watches, +repair accounting, alter enforcement or register per-path metrics. + +**This is not an atomic or historical snapshot.** Stat and limit records are +sampled independently with no tree-wide lock; they can reflect different +instants and existing quota-accounting lag. Identity rechecks detect some +removals/replacements, not every concurrent change. `available=true` is a +metadata-availability statement, not a freshness, consistency or safety +guarantee. Missing or invalid data must remain unknown. + +Continue using `monitor/mntr` for existing response-size, session/connection, +latency and queue metrics, and `watch_summary/wchs` for watch totals. +`quota_stats` does not duplicate those metrics or introduce namespace labels. + diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAuditLogs.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAuditLogs.md index b3954ad676d..586767933ed 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAuditLogs.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAuditLogs.md @@ -17,7 +17,9 @@ limitations under the License. # ZooKeeper Audit Logging * [ZooKeeper Audit Logs](#ch_auditLogs) -* [ZooKeeper Audit Log Configuration](#ch_reconfig_format) +* [ZooKeeper Audit Log Configuration](#ch_auditConfig) +* [Enhanced audit metadata (schema v2)](#ch_auditV2) +* [Session and authentication bindings](#ch_auditBindings) * [Who is taken as user in audit logs?](#ch_zkAuditUser) @@ -34,11 +36,11 @@ The audit log captures detailed information for the operations that are selected | Key | Value | | ----- | ----- | |session | client session id | -|user | comma separated list of users who are associate with a client session. For more on this, see [Who is taken as user in audit logs](#ch_zkAuditUser). +|user | comma separated list of users associated with a write request, or one sanitized principal on each v2 [binding record](#ch_auditBindings). For more on this, see [Who is taken as user in audit logs](#ch_zkAuditUser). |ip | client IP address -|operation | any one of the selected operations for audit. Possible values are(serverStart, serverStop, create, delete, setData, setAcl, multiOperation, reconfig, ephemeralZNodeDeleteOnSessionClose) +|operation | any one of the selected operations for audit. Possible values are(serverStart, serverStop, create, delete, setData, setAcl, multiOperation, reconfig, ephemeralZNodeDeletionOnSessionCloseOrExpire). Enhanced logging also adds sessionEstablished and authentication. |znode | path of the znode -|znode type | type of znode in case of creation operation +|znode_type | type of znode in case of creation operation |acl | String representation of znode ACL like cdrwa(create, delete,read, write, admin). This is logged only for setAcl operation |result | result of the operation. Possible values are (success/failure/invoked). Result "invoked" is used for serverStop operation because stop is logged before ensuring that server actually stopped. @@ -99,6 +101,251 @@ Audit logging is done using logback. Following is the default logback configurat Change above configuration to customize the auditlog file, number of backups, max file size, custom audit logger etc. + + +## Enhanced audit metadata (schema v2) + +The optional Java system property `zookeeper.audit.enhanced.enable` defaults to +`false`. Set `-Dzookeeper.audit.enhanced.enable=true` in addition to enabling +`zookeeper.audit.enable` to emit v2 records. Enhanced logging does not enable +audit logging by itself. With enhanced logging off, ordinary legacy formatting +and the existing logging APIs are preserved. TTL creates are audited in both +modes, including sequential TTL creates. + +V2 keeps the existing field names and `result=success/failure/invoked`, and adds: + +| Key | Meaning | +| --- | --- | +| `schema_version` | `2`. Its absence identifies legacy output. | +| `data_length` | Attempted payload size in bytes for create variants and `setData`, not request size, character count, or resulting znode size. Null and empty payloads are `0`; unavailable or undecodable payloads omit this field. A failed attempt can have a nonzero length. | +| `error_code` | Known numeric ZooKeeper code, such as `0` (OK), `-101` (NONODE), `-102` (NOAUTH), `-103` (BADVERSION), or `-110` (NODEEXISTS). Omitted when unavailable. | +| `outcome` | `committed`, `failed`, `rolled_back`, or `unknown`, as described below. | +| `cxid` | Known client request ID in decimal. All members of a multi share this ID. | +| `zxid` | Known transaction ID in decimal, not the server's latest zxid at reply time. A failed transaction may also have a zxid. | +| `multi_index` | Zero-based position in the **complete** multi request, including non-mutating checks. Omitted on single operations and multi parent records. | +| `auth_scheme` | Authentication scheme paired with the single sanitized `user` on a session/authentication binding record. Omitted when unknown and on write records. | + +No znode payloads are included. For enhanced `setAcl` records, `acl` retains +schemes and permissions, but identities are conservative: `world:anyone` is +retained, valid built-in digest identities use the provider's username extraction +(not the digest), and other identities are `[redacted]`. This also applies to +failed ACL attempts, where identities might be malformed or contain credentials. +Unknown custom identity representations are not printed as ACL identities. + +For server-generated v2 records, the `user` field also uses a conservative +provider policy rather than trusting a custom provider's default `getUserName`. +Only the concrete built-in digest, IP, SASL and X509 providers are trusted, and +their identity syntax must validate before their username extraction is used. +Digest credentials are reduced to usernames; X509 identities are distinguished +names, not certificate bodies. Unknown or malformed identities, custom providers +and provider subclasses are represented as `[redacted]`. Merely overriding +`getUserName` does not opt a custom provider into this trust policy. Legacy user +extraction is unchanged when enhanced logging is off. + +Custom callers of the logging APIs must supply sanitized user and ACL metadata; +the string-based APIs cannot infer credentials from arbitrary strings. + +### Results and transaction outcomes + +* `committed` means the available transaction result reports an applied write. + These records have `result=success` and `error_code=0`. It does not guarantee + that the reply reached the client or the log reached durable storage. +* `failed` means the operation or whole multi failed according to its known + result. For rejected single transactions, the request exception takes + precedence when the reply path uses it instead of the transaction error. +* `rolled_back` means a mutation was not applied because its atomic multi + failed. This includes prepared members discarded by rollback and members + skipped after the failure. They have `result=failure`, **even when their + individual `error_code` is `0`**. Later skipped members normally have code + `-2` (RUNTIMEINCONSISTENCY); a first failure with that code is still `failed`. +* For transaction records, `unknown` means the available metadata cannot + establish the transaction's outcome. Missing or mismatched multi results are not treated as commits, + and untrustworthy per-member error codes are omitted. If the whole multi + is known to have failed, these members still have `result=failure`; + otherwise unknown transactions use `result=invoked`. Non-transactional + lifecycle and successful binding events also use `outcome=unknown`; their + separate `result` retains the known lifecycle or authentication result. + +Failed multis keep their `multiOperation` parent failure record, followed in v2 +by records for the attempted mutations when the request is decodable. The parent +is retained even if request decoding fails. Successful multis emit individual +mutation records, not a parent success record. Request and result members are +paired by position, so repeated paths cannot overwrite each other's metadata. +Successful sequential creates use the final path returned by the transaction; +failed or rolled-back creates use the attempted path. + +### Supported operations and identity + +The audited write operations remain creates (`create`, `create2`, container and TTL +variants), deletes (including container deletes), `setData`, `setAcl`, write +multis, reconfiguration, server start/stop, and existing system ephemeral-node +deletions. Ordinary reads, read multis and checks do not produce audit records. +Checks inside a write multi still occupy an index. + +Existing lifecycle events and callers of the legacy logging overloads receive +`outcome=unknown` in v2 when they do not supply transaction metadata; their +existing `result` is unchanged. Enhanced session/authentication events are +described below; they do not claim a committed transaction. + +System ephemeral-node deletion records preserve the **server actor**, the +affected session and path, and omit client IP. V2 adds the deletion transaction's +zxid, known error code and outcome. A client request ID is not available at this +deletion hook, so it is omitted. These system records can be emitted on multiple +replicas or during replay. Downstream consumers can deduplicate using ensemble +identity plus `zxid`, `operation`, `session` and `znode`; client multi mutations +also require `multi_index` to distinguish repeated paths. + + + +### Session and authentication bindings + +Both existing audit gates must be enabled for these additional operations. +With audit logging disabled or enhanced logging disabled, neither operation is +emitted. Authentication, SASL failure policy, TLS, ACL evaluation and client +responses are unchanged. + +* `operation=sessionEstablished`, `result=success` records a **valid session + attachment**, including reconnects and movement to another server. It does + not necessarily mean a new session was created. The hook runs after the + connect response has been queued and before receiving further requests is + enabled. Invalid session/password revalidation does not produce these records. + Each record binds one identity already attached to that connection. +* `operation=authentication` records an explicit auth provider's accepted or + rejected outcome, or SASL completion/failure. Intermediate SASL challenges + do not produce successes. Successful binding records snapshot the accepted + **scheme's currently attached identities**, including earlier identities of + the same scheme; they are not a delta or proof that each principal was + newly authenticated by this packet. A failure record does not extract a + principal from credentials or blame an identity already on the connection. + +These operations emit one record per distinct sanitized `(auth_scheme, user)` +binding. `auth_scheme` and the scalar `user` belong to the **same record**; +neither field is a parallel comma-separated list. For example, one attachment +with IP, digest and X509 identities produces three records: + +```text +session=0x123 user=127.0.0.1 ip=127.0.0.1 operation=sessionEstablished schema_version=2 outcome=unknown auth_scheme=ip result=success +session=0x123 user=alice ip=127.0.0.1 operation=sessionEstablished schema_version=2 outcome=unknown auth_scheme=digest result=success +session=0x123 user=CN=Bob,OU=Audit ip=127.0.0.1 operation=sessionEstablished schema_version=2 outcome=unknown auth_scheme=x509 result=success +``` + +Binding order is unspecified. Duplicate sanitized pairs within a single hook +are collapsed, including distinct credentials for the same username or +multiple untrusted identities that redact to the same pair. A later attachment +or auth outcome can emit that pair again. Record counts are therefore **not** +session-creation or authentication-attempt counts. + +Use ordinary v2 field unescaping only; there is no JSON grammar inside `user`. +Commas, quotes and equals signs are part of a scalar principal, not binding +separators. Write-record `user` formatting is unchanged. + +An attachment without identities emits one record with `user` and `auth_scheme` +omitted. Ordinary anonymous network clients normally still have an `ip` +identity; this is not proof of credential-based authentication. Unknown fields +are omitted, not encoded as a literal `null` username. Failed digest +authentication has `auth_scheme=digest` and no `user`. A provider that accepts +authentication without attaching a matching identity also has no `user`. +A missing scheme is omitted. An existing but untrusted or +malformed identity is `[redacted]`, using the same conservative concrete-provider +policy as other server-generated v2 records. Custom providers and subclasses +do not opt into trusted extraction through `getUserName`. No auth packet bytes, +SASL tokens, passwords, digest secrets or certificate bodies are logged. + +| Field | Binding-event meaning | +| --- | --- | +| `session` | Known connection session ID in hexadecimal. Omitted when the ID is not assigned (zero), including pre-session authentication failures. No replacement session ID is invented. | +| `auth_scheme` | Scheme of the identity or the authentication outcome. Omitted when unknown. | +| `user` | One sanitized principal bound to `auth_scheme`. Omitted for failed authentication or when no matching identity is known. | +| `ip` | Client address available on the connection. | +| `result` | `success` for valid attachments or accepted authentication; `failure` for rejected authentication, even if SASL policy allows the connection to remain open. | +| `error_code` | Authentication outcome: `0` for success, `-115` (AUTHFAILED) for failure. Omitted on attachment events. This is not the SASL policy's eventual reply code: allowed failed clients can receive OK, while required-SASL failures can receive SESSIONCLOSEDREQUIRESASLAUTH. | +| `outcome` | `unknown` for a successful, non-transactional binding; `failed` for authentication failure. Never `committed`. | +| `cxid` | Actual auth/SASL request ID, when a packet supplied one. These protocol IDs can repeat and are not unique event IDs. Omitted for session attachment. | + +There is no binding `zxid`, znode, payload size or multi index. The +`sessionEstablished` hook is shared by standalone and quorum servers, including +learner revalidation. An attachment's records describe one snapshot on the +attached server, not a new binding on every replica. All identities are +sanitized before the first record is emitted, and the entire group uses one +captured enhanced mode. A later authentication outcome is a separate stage, +not a duplicate attachment. + +TLS authentication can populate an X509 identity **before** session assignment. +The later attachment snapshot correlates that identity with the valid session; +it does not invent an earlier session ID or a second X509 authentication +outcome. Neither an `x509` nor a `super` identity establishes the transport +security state. A `super` identity remains conservatively redacted. DN-based +X509 identities use the existing built-in validation; SAN-derived identities +that do not validate as DNs and custom TLS identities remain redacted. + +Request-time write identities remain authoritative for those writes. Later +authentication does not rewrite earlier audit history, and reconnecting does +not transfer another connection's authentication information. Consumers must +not treat an attachment snapshot as the identity for every subsequent write. + +These are best-effort server observations, not evidence of client receipt or +durable log delivery. TLS handshake failures before session initialization, +malformed packets that never reach an auth outcome, and a missing SASL server +are not newly instrumented here. Metadata failures can omit a binding record; +the existing best-effort `audit_errors` reporter is reused without changing +authentication or session behavior, even if the counter or diagnostic logger +also fails. + +### Escaping and parsing + +Fields are separated by literal tabs. Split each field at its **first** equals +sign; equals signs inside values are unchanged. In v2 only, value formatting +reversibly escapes backslash as `\\`, tab as `\t`, carriage return as `\r`, and +newline as `\n`. A literal backslash followed by `t` is therefore `\\t`, distinct +from an escaped tab. Decode left to right, consuming one escape pair at a time; +do not use successive global replacements to unescape. For example, the user +value containing a tab and newline is rendered as `user=team\tname\n` on a single +physical log line. Enum-derived keys and values are independent of the server's +default locale. Custom `AuditLogger` implementations receive raw event values; +`AuditEvent.toString()` is the production text-formatting path. + +### Mixed versions, rollout, rollback and coverage + +Consumers should recognize v2 per record using `schema_version=2` and accept +omitted optional fields. Consumers designed for forward compatibility should +tolerate unknown additive fields, but the implemented local JOBS consumer is +pinned to the current v2 schema and strict: its parser rejects unknown fields +as `unknown_field`, and local staging quarantines those records. Update and +validate that consumer before enabling additive producer fields, even when +the producer retains `schema_version=2`. + +Do not apply v2 unescaping to legacy records. Prepare consumers for mixed legacy/v2 output before +enabling the property on servers, then roll it out gradually. Roll back emission +by removing the enhanced property or setting it to `false` on restart. Each +audited request captures the enhanced mode before metadata extraction, and uses +that same decision for user/ACL sanitization, schema construction and every +parent/member record of a multi. A connection-binding hook likewise captures +one mode before identity extraction and carries it through emission. An in-flight request keeps its captured mode if +the property changes; a subsequent request captures the new value. Direct +provider logging captures its mode per event. The base audit enablement retains +its startup behavior. No wire, persistence, authentication, ACL, quota or payload +limit changes are required. + +The bounded-cardinality server counter `audit_errors` counts detected audit +metadata extraction/correlation failures and runtime failures reported by the +audit logger. Such failures are logged without rejecting an otherwise valid +client operation. A metadata error can still yield an audit record with omitted +fields, so this counter is **not** a dropped-record count. It has no per-path, +per-user or per-session labels. +The counter update and diagnostic error logging are independently best-effort: +runtime failures in either reporting backend are isolated without retries, so +they cannot reject an applied write or interrupt system deletion. A failing +metrics backend can therefore also leave detected audit errors uncounted. + +Audit coverage is limited to the selected transaction and binding hooks. Rejections +before a hook, other connection-level failures and later reply/send failures need +separate instrumentation; these records are not a complete request-delivery +ledger. Log delivery is also a separate concern: an asynchronous appender using +`neverBlock=true` can discard records without reporting an exception. +`audit_errors` cannot measure those silent drops, filtering, retention loss, or +downstream delivery gaps. Validate source-log coverage and delivery independently +before relying on the stream for complete accounting. + ## Who is taken as user in audit logs? @@ -114,7 +361,7 @@ User is decided based on the configured authentication provider: * When IPAuthenticationProvider is configured then authenticated IP is taken as user * When SASLAuthenticationProvider is configured then client principal is taken as user -* When X509AuthenticationProvider is configured then client certificate is taken as user +* When X509AuthenticationProvider is configured then its certificate-derived identity (normally the subject DN) is taken as user, not the certificate body * When DigestAuthenticationProvider is configured then authenticated user is user Custom authentication provider can override org.apache.zookeeper.server.auth.AuthenticationProvider.getUserName(String id) diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md b/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md index 94dbd7cfc4e..0fa468e110a 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md @@ -23,6 +23,9 @@ limitations under the License. * [zkCleanup.sh](#zkCleanup) * [zkTxnLogToolkit.sh](#zkTxnLogToolkit) * [zkSnapShotToolkit.sh](#zkSnapShotToolkit) + * [zkSnapshotComparer.sh](#zkSnapshotComparer) + * [zkSnapshotRecursiveSummaryToolkit.sh](#zkSnapshotRecursiveSummaryToolkit) + * [zkOfflineAudit.sh](#zkOfflineAudit) * [Testing](#Testing) * [Jepsen Test](#jepsen-test) @@ -212,6 +215,362 @@ USAGE: SnapshotFormatter [-d|-json] snapshot_file [[1,0,{"progname":"SnapshotFormatter.java","progver":"0.01","timestamp":1559788148637},[{"name":"\/","asize":0,"dsize":0,"dev":0,"ino":1001},[{"name":"zookeeper","asize":0,"dsize":0,"dev":0,"ino":1002},{"name":"config","asize":0,"dsize":0,"dev":0,"ino":1003},[{"name":"quota","asize":0,"dsize":0,"dev":0,"ino":1004},[{"name":"test","asize":0,"dsize":0,"dev":0,"ino":1005},{"name":"zookeeper_limits","asize":52,"dsize":52,"dev":0,"ino":1006},{"name":"zookeeper_stats","asize":15,"dsize":15,"dev":0,"ino":1007}]]],{"name":"test","asize":0,"dsize":0,"dev":0,"ino":1008}]] ``` + + +### zkSnapshotComparer.sh + +Compare the subtree data sizes and descendant counts in two snapshot files. +This is the native backport of [ZOOKEEPER-3427](https://github.com/apache/zookeeper/commit/f90060b83da4bfcca58ada93a57fedb40a069387). +The tool reports paths found only in the left or right snapshot and size/count +deltas for paths present in both. Deltas are **right minus left**. + +```bash +bin/zkSnapshotComparer.sh --left snapshot.1 --right snapshot.2.gz --bytes 0 --nodes 0 +``` + +All four options are required: + +* `-l`, `--left`: left snapshot file. +* `-r`, `--right`: right snapshot file. +* `-b`, `--bytes`: non-negative byte-difference threshold. +* `-n`, `--nodes`: non-negative descendant-count-difference threshold. + +Thresholds are integers from 0 through 2147483647. A path is printed when +**either** absolute difference is **strictly greater** than its threshold. +For a path present in only one snapshot, its subtree size and descendant count +are compared to the thresholds. Data sizes include the node's own payload and +all descendant payloads; descendant counts exclude the node itself. Null data +contributes zero bytes. A zero-byte leaf present in only one snapshot is +therefore filtered even with both thresholds set to zero. + +Use `-d`, `--debug` to display filtered paths and comparison details. Use +`-i`, `--interactive` to explore the snapshots interactively: + +* Press Enter to print the current depth and advance. +* Enter a non-negative depth to jump to that layer (the root is depth 0). +* Enter an absolute path, including `/`, to compare its immediate children. +* End input or press Ctrl-C to stop before all layers have been compared. + +The batch report visits each depth and sorts paths alphabetically within it. +It retains the upstream empty label for the root in comparison lines. + + + +### zkSnapshotRecursiveSummaryToolkit.sh + +Recursively summarize one snapshot subtree. This is the native backport of +[ZOOKEEPER-4566](https://github.com/apache/zookeeper/commit/05b215994f5e145c2758c4089828b57ba471b329). + +```bash +bin/zkSnapshotRecursiveSummaryToolkit.sh snapshot.1 /app 1 +``` + +Usage: `SnapshotRecursiveSummary `. +The starting node must be an existing absolute znode path. The maximum depth is +a non-negative integer: 0 prints every non-leaf node, 1 prints the starting +node and its non-leaf children, 2 adds another level, and so on. +**Depth limits output only, not traversal or totals.** + +`children` is the number of all descendants, not just immediate children. +`data` is the sum of payload bytes for the node itself and all descendants. +Null data contributes zero bytes. Leaves contribute to their ancestors' totals +but are not printed; selecting a leaf produces no summary entries. + +For example, if `/app` has 2 payload bytes, `/app/branch` has 3, and +`/app/branch/leaf` has 5, the summary is: + +```text + /app + children: 2 + data: 10 +-- /app/branch +-- children: 1 +-- data: 8 +``` + +#### Snapshot analysis limitations + +Both tools run offline, read files without modifying them, and support +uncompressed, `.gz`, and `.snappy` snapshots (including mixed formats in the +comparer). They validate snapshot checksums and report unreadable or corrupt +input rather than silently producing a successful analysis. Invalid arguments, +file paths or summary starting paths exit with code 2; snapshot read failures +exit with code 1. Windows launchers with the same names and a `.cmd` extension +are also included. The launchers use the existing `zkEnv` configuration. + +Both tools **include ephemeral znodes** present in the snapshot; they do not +report session records. This reflects the upstream traversal behavior, despite +the original comparer's description claiming that ephemerals were ignored. +Neither tool compares payload contents, ACLs, versions or other znode metadata. +**Equal sizes/counts do not prove identical contents.** Snapshots may be fuzzy: +these tools do not replay transaction logs, reconstruct point-in-time state, +or establish transaction-consistent equality. The tools load snapshots into +memory, and traversal still visits the full selected subtree. + + + +### zkOfflineAudit.sh + +Export versioned, non-value metadata from **one explicitly selected snapshot**. +The Windows equivalent is `bin/zkOfflineAudit.cmd`; the Java entry point is +`org.apache.zookeeper.server.OfflineAuditExporter`. + +```bash +bin/zkOfflineAudit.sh --snapshot-file snapshot.100000001.gz --output-dir audit-run +``` + +The output directory **must not already exist**, even if empty, and its parent +must exist. Use a new, owned directory for each invocation. Existing files, +directories, hard links and symbolic links at the destination are rejected +without modifying them. Filesystem path resolution preserves symbolic-link +parent semantics; it does not lexically remove `..` and select a different file. +The tool never renames, rewrites or deletes the source, connects to a ZooKeeper +server, replays transaction logs, or selects a fallback snapshot. + +The shared native reader accepts format-version 2 snapshots, uncompressed or +with the native `.gz`/`.snappy` suffix. It checks the header, body seal and any +optional digest seal, rejects partial trailers, invalid roots and duplicate node +records, and consumes the decoded stream to its end. A digest is recorded as metadata, **not** compared +as a transaction-consistent recovery digest. Fuzzy snapshots do not need +transaction-log coverage for this export. + +#### Resources, publication and failures + +The native reader loads one complete `DataTree`, its ACL cache and session +indexes. Budget heap for the **decoded** tree, not just the compressed file or +stored value bytes. The exporter adds an iterative traversal stack, sorted child +name references for the active ancestors, one JSON record at a time, and a copy +of one session's ephemeral path set at a time. It does not build a second tree +or retain all output records. Native quota initialization can still recurse +during loading; an unusually deep quota tree can exhaust the JVM stack. + +Output can exceed snapshot size substantially: every node includes full paths +and metadata, and deep paths are repeated. Disk policy must also allow filesystem +allocation overhead. The optional `--max-output-bytes BYTES` is a **positive, +decimal signed-64-bit integer** bounding the combined UTF-8 bytes of the three +NDJSON files and the manifest, including newlines. No production budget is +assumed when it is absent. This is not an input-size, heap, stack, runtime or +filesystem-block quota. Supply approved worker budgets and JVM limits through +the existing launcher environment; do not infer safe limits from this example. + +The tool creates a private directory (mode 0700 on POSIX), creates output files +without replacement, closes and forces the data files, stages the manifest, +rechecks source identity, and **atomically publishes `manifest.json` last**. +Atomic move support is required; there is no non-atomic fallback. Source checks +compare SHA-256 of the original file bytes, resolved path, size, precise mtime, +creation time, available file key and Unix change time where supported, including +stat checks around hashing. Access time is deliberately not compared because +reading may update it. Use immutable, owned input copies: these checks are not +a lock against concurrent writers, and identity/time precision depends on the +filesystem. + +Exit 0 means publication succeeded. Invalid arguments, decoder properties or an +existing destination exit 2 with usage; snapshot, source-change, output-budget +and I/O failures exit 1 with diagnostics. JVM resource errors remain visible +and nonzero rather than becoming an empty-database success. A failed started +export can leave partial files and `.manifest.json.inprogress` for diagnosis; +**neither is a published result** and the directory cannot be reused. An +existing destination is rejected *before an export is attempted*: its prior +manifest is left untouched, not relabeled as this invocation's result. Consumers +must require a successful invocation into their unique run directory, read only +`manifest.json`, and verify its source identity, schema, file counts and hashes. +The file descriptors omit the manifest itself to avoid a checksum cycle. + +#### Decoder configuration and owner uncertainty + +Use JVM system properties (for example through `JVMFLAGS`) only when the source +settings are known: + +* `zookeeper.extendedTypesEnabled`: `true` or `false`, case-insensitive. +* `zookeeper.emulate353TTLNodes`: `true` or `false`, case-insensitive. Explicit + `true` requires explicit `zookeeper.extendedTypesEnabled=true`. +* `jute.maxbuffer`: positive 32-bit integer in the native Java numeric-property + syntax (`Integer.decode`: decimal, `0x`/`#` hexadecimal or leading-zero octal). + When absent, the native `BinaryInputArchive.maxBuffer` default applies. +* `zookeeper.jute.maxbuffer.extrasize`: non-negative 32-bit integer in the same + syntax. When absent it defaults to `jute.maxbuffer`; the native minimum extra + padding of 1024 bytes is applied. The effective sum must fit a signed 32-bit + length. These decoder bounds are not estimates of server response limits. + +Malformed properties are rejected instead of silently using Java's fallback. +Compression is detected by the selected file's suffix, not the output +compression system property. The optional digest is read regardless of the +local digest-calculation setting. Arbitrary JVM properties are not exported. + +Snapshots do not persist the source's type settings. Absent source properties +are therefore `null` in the manifest, although the native decoder's effective +boolean defaults are false. Owner 0 is persistent, `0x8000000000000000` is a +container, and nonzero sign-bit-clear owners are normal ephemerals. Other owners +are classified as follows: + +| Known source configuration | Classification of other negative owners | +| --- | --- | +| Extended types unspecified | `unknown`; never grouped as sessions | +| Extended types explicitly false | Native normal ephemeral/session | +| Extended types true, emulation unspecified | `unknown`; legacy interpretation is unresolved | +| Extended types true, emulation false | Native modern TTL or normal ephemeral; unsupported extended feature bits fail | +| Extended types true, emulation true | Native 3.5.3-emulated TTL | + +`ttl_ms` uses this decoder's `EphemeralType.TTL.getValue(owner)` (low 40 bits), +including emulation; it does not reconstruct a historical implementation's +different value mask. It is a duration, **not** a calculated expiry time. +Neither an owner in `sessions.ndjson` nor membership in the snapshot session +table proves a currently live session. + +#### Schema version 1 + +All files are UTF-8. Each NDJSON line is one complete JSON object; `manifest.json` +is one JSON object. `null` means unknown/not applicable, not zero. `integer` +below means a JSON integral number (64-bit arithmetic for counts, lengths, +byte totals and times); schema/format/digest versions, node version counters, +session timeouts and quota counts are 32-bit integers. No field is a JVM-memory +estimate. **All 64-bit zxids, owner/session identifiers and digest values are +lowercase, zero-padded, 16-digit hex strings prefixed with `0x`**, preserving +their unsigned bit patterns. SHA-256 values are 64 lowercase hex digits without +a prefix. Consumers must not convert identifiers to floating-point numbers. + +**`nodes.ndjson`** includes the root exactly once as `/`, all customer nodes and +the reserved subtree. Records are emitted in deterministic depth-first +**preorder** with sorted children. Each parent's descendants are contiguous, +supporting streaming subtree intervals. This is not a global lexical path sort: +`/a/x` follows `/a` before the traversal advances to `/a-`. + +| Field | Type | Exact meaning | +| --- | --- | --- | +| `schema_version` | integer | `1` | +| `path` | string | Full absolute decoded znode path; root is `/`, never `""` | +| `data_length` | integer | This decoded node's payload length in bytes; null data contributes 0 | +| `num_children` | integer | Number of **immediate** children, not descendants | +| `getchildren_response_bytes` | integer | Full successful getChildren reply, defined below | +| `getchildren2_response_bytes` | integer | Same reply plus the 68-byte Stat | +| `ctime_ms`, `mtime_ms` | integer | Stored node creation/data-modification timestamps, epoch milliseconds | +| `czxid`, `mzxid`, `pzxid` | hex string | Stored creation, data-modification and child-modification zxids | +| `data_version`, `acl_version` | integer | Persisted data/ACL version counters | +| `persisted_cversion` | integer | Raw persisted child-create counter; not the client Stat cversion conversion | +| `path_utf8_bytes` | integer | Full absolute path's UTF-8 length, including leading slash, without a length prefix | +| `node_type` | string | `persistent`, `ephemeral`, `container`, `ttl` or `unknown` | +| `ephemeral_owner` | hex string | Raw persisted owner bits, including 0, TTL/container and unknown encodings | +| `owner_encoding` | string | `none`, `session`, `container`, `ttl`, `ttl-3.5.3` or `unknown` | +| `ttl_ms` | integer or null | Native decoded TTL duration for known TTL types only | +| `acl_risk_flags` | array of strings | Non-secret review hints below; empty means no listed hint, not proven safe | + +The getChildren size is +`16 + 4 + sum(4 + UTF-8(child-name).length)`: the 16-byte ReplyHeader, four-byte +vector count, and four-byte length plus bytes for each **child name**, not full +path. getChildren2 adds 68 bytes. Both **exclude** the outer four-byte framing +prefix and transport/TLS overhead. Empty vectors are 20/88 bytes; names `é`, `a` +are 31/99 bytes. Counts use `long` arithmetic, independent of any packet limit. +Actual Jute serialization agrees for valid znode names. This branch's older +Jute encoder treats surrogate pairs differently from standard UTF-8, but native +`PathUtils` prohibits those characters in znode paths; malformed decoded paths +are rejected, not silently relabeled. + +ACL hints do not disclose ACL scheme strings or IDs, change permissions, or +perform authentication/authorization: + +| Flag | Meaning | +| --- | --- | +| `world_read` | `world:anyone` has READ | +| `world_write` | `world:anyone` has any of WRITE, CREATE or DELETE | +| `world_admin` | `world:anyone` has ADMIN | +| `auth` | An `auth` scheme entry remains in the decoded ACL | +| `unknown_scheme` | Scheme outside the known set `world`, `auth`, `digest`, `ip`, `sasl`, `x509`; custom providers require review | +| `missing_acl` | Decoded ACL list is null or empty | +| `invalid_permissions` | Permission bits outside `ZooDefs.Perms.ALL` | +| `invalid_identity` | Missing identity/scheme/ID, or a `world` identity other than `anyone` | + +Native deserialization can reject malformed ACL structures before flags can be +produced. Node values and raw ACL identities are never written to the export. +Paths themselves can be sensitive; protect the output as metadata. + +**`namespaces.ndjson`** contains **every** immediate root child except exactly +`/zookeeper`, including leaves and `/zookeeper-client`. Counts/bytes include the +namespace node and all descendants of every node type. Root data is not assigned +to a namespace. Records follow traversal order. + +| Field | Type | Exact meaning | +| --- | --- | --- | +| `schema_version` | integer | `1` | +| `path` | string | Top-level customer namespace path | +| `subtree_node_count` | integer | Inclusive subtree node count | +| `payload_bytes` | integer | Sum of decoded payload lengths; null contributes 0 | +| `path_utf8_bytes` | integer | Sum of full absolute path lengths in UTF-8 | +| `quota_status` | string | `absent`, `valid` or `invalid` for the exact namespace limit znode | +| `quota_limit_count` | integer or null | Parsed `count`, including -1 (unset); null if absent/invalid | +| `quota_limit_bytes` | integer or null | Parsed `bytes`, including -1 (unset); null if absent/invalid | +| `quota_stat_present` | boolean | Exact namespace quota stat znode exists; not evidence of freshness | + +Quota availability refers specifically to +`/zookeeper/quota/zookeeper_limits` and `zookeeper_stats`; it does not +claim absence of quotas on descendants or model inherited enforcement. A valid +limit has the native `count=,bytes=` format with values at least -1. +Malformed limits are flagged without exporting their raw value. Native DataTree +loading **rebuilds quota-stat payloads in memory**; node lengths under the +reserved quota subtree describe that decoded tree, not necessarily the original +stat-node payload lengths. Customer subtree totals are computed independently +from the decoded customer nodes; no rebuilt quota count is trusted for them. + +**`sessions.ndjson`** has one record per owner with decoded normal ephemerals. +It excludes containers, known TTLs, unknown encodings and session-table entries +with no normal ephemeral nodes. Session ordering is unspecified; records are +streamed from the native index, including ephemerals in the reserved subtree. + +| Field | Type | Exact meaning | +| --- | --- | --- | +| `schema_version` | integer | `1` | +| `session_id` | hex string | Normal ephemeral owner under the known/unambiguous decoder interpretation | +| `ephemeral_node_count` | integer | Number of nodes owned by that session | +| `payload_bytes` | integer | Sum of those nodes' payload lengths | +| `path_utf8_bytes` | integer | Sum of those nodes' full UTF-8 path lengths | +| `present_in_snapshot_session_table` | boolean | Owner also appears in the serialized session/timeout table | +| `timeout_ms` | integer or null | Serialized timeout if present; null otherwise, not a remaining lifetime | + +**`manifest.json`** is the sole publication marker. Export completeness means +complete traversal/output of this decoded snapshot, **not** current-state, +backup completeness or transaction-consistent recovery. + +| Field | Type | Exact meaning | +| --- | --- | --- | +| `schema_version` | integer | `1` | +| `tool` | object | `name` = `OfflineAuditExporter`; `version` = native `Version.getFullVersion()` string | +| `export_id` | string | New UUID identifying this published export | +| `export_complete` | boolean | `true` in a successfully published manifest only | +| `recovery_scope` | string | Always `snapshot-only` | +| `transaction_logs_replayed` | boolean | Always `false` | +| `source_capture_time_ms` | null | Capture time unknown; never inferred from file or node timestamps | +| `source_capture_provenance` | null | No authoritative capture provenance supplied to this CLI | +| `source_server_version` | null | Snapshot header version does not identify the source server release | +| `source` | object | Source descriptor defined below | +| `decoder` | object | Decoder settings defined below | +| `snapshot_zxid` | hex string or null | Label parsed only from `snapshot.<1..16 hex digits>[.gz or .snappy]`; renamed/range filenames yield null; not a consistent endpoint | +| `last_observed_zxid` | hex string | Unsigned maximum of all exported nodes' `czxid`, `mzxid`, `pzxid`; excludes filename/digest zxids | +| `last_observed_zxid_scope` | string | Always `maximum-node-czxid-mzxid-pzxid-unsigned` | +| `snapshot_digest` | object or null | Optional stored digest metadata, not recovery evidence | +| `unknown_owner_nodes` | integer | Count of node records with `node_type=unknown` | +| `max_output_bytes` | integer or null | Requested output byte budget, or no CLI budget | +| `files` | array of objects | Exactly three output descriptors, in nodes/namespaces/sessions order | + +Nested objects have these exact fields: + +* `source`: `path` (resolved absolute filename string), `sha256` (hash of the + original **compressed bytes**, if compressed), `size_bytes` (integer), + `mtime_ms` (integer epoch milliseconds), `change_time_ms` (Unix inode-change + time in epoch milliseconds, or null if unavailable), `compression` (`checked` + = uncompressed, `gzip` or `snappy`), `format_version` (integer, currently 2), + `database_id` (raw header dbid as a hex string). None of the file times is + snapshot capture time. +* `decoder`: `source_extended_types_enabled` and `source_emulate_353_ttl_nodes` + (boolean or null); `effective_extended_types_enabled` and + `effective_emulate_353_ttl_nodes` (boolean); `jute_maxbuffer` and + `jute_extra_maxbuffer` (integers after native default/minimum handling). +* `snapshot_digest`, when present: `zxid` (hex string), `version` (integer), + `value` (hex string), `seal_validated` (true), + `transaction_consistency_verified` (false). A native zero/empty digest is + retained as zero, not promoted to completeness evidence. +* Each `files` entry: `name` (exactly `nodes.ndjson`, `namespaces.ndjson` or + `sessions.ndjson`), `records` (integer line count), `size_bytes` (integer + physical file length), `sha256` (hash of all file bytes, including newlines). + ## Testing diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditConstants.java b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditConstants.java index 22fd8567abe..2d5e3eef79f 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditConstants.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditConstants.java @@ -18,6 +18,8 @@ package org.apache.zookeeper.audit; public final class AuditConstants { + public static final String SCHEMA_VERSION = "2"; + private AuditConstants() { //Utility classes should not have public constructors } @@ -31,4 +33,6 @@ private AuditConstants() { public static final String OP_MULTI_OP = "multiOperation"; public static final String OP_RECONFIG = "reconfig"; public static final String OP_DEL_EZNODE_EXP = "ephemeralZNodeDeletionOnSessionCloseOrExpire"; + public static final String OP_SESSION_ESTABLISHED = "sessionEstablished"; + public static final String OP_AUTHENTICATION = "authentication"; } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditEvent.java b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditEvent.java index e499552a948..d830570b72b 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditEvent.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditEvent.java @@ -18,6 +18,7 @@ package org.apache.zookeeper.audit; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -43,12 +44,12 @@ public Set> getLogEntries() { void addEntry(FieldName fieldName, String value) { if (value != null) { - logEntries.put(fieldName.name().toLowerCase(), value); + logEntries.put(fieldName.name().toLowerCase(Locale.ROOT), value); } } public String getValue(FieldName fieldName) { - return logEntries.get(fieldName.name().toLowerCase()); + return logEntries.get(fieldName.name().toLowerCase(Locale.ROOT)); } public Result getResult() { @@ -63,6 +64,7 @@ public Result getResult() { @Override public String toString() { StringBuilder buffer = new StringBuilder(); + boolean enhanced = AuditConstants.SCHEMA_VERSION.equals(getValue(FieldName.SCHEMA_VERSION)); boolean first = true; for (Map.Entry entry : logEntries.entrySet()) { String key = entry.getKey(); @@ -75,7 +77,7 @@ public String toString() { buffer.append(PAIR_SEPARATOR); } buffer.append(key).append(KEY_VAL_SEPARATOR) - .append(value); + .append(enhanced ? escape(value) : value); } } //add result field @@ -83,16 +85,27 @@ public String toString() { buffer.append(PAIR_SEPARATOR); } buffer.append("result").append(KEY_VAL_SEPARATOR) - .append(result.name().toLowerCase()); + .append(result.name().toLowerCase(Locale.ROOT)); return buffer.toString(); } + private static String escape(String value) { + return value.replace("\\", "\\\\") + .replace("\t", "\\t") + .replace("\r", "\\r") + .replace("\n", "\\n"); + } + public enum FieldName { - USER, OPERATION, IP, ACL, ZNODE, SESSION, ZNODE_TYPE + USER, OPERATION, IP, ACL, ZNODE, SESSION, ZNODE_TYPE, + SCHEMA_VERSION, DATA_LENGTH, ERROR_CODE, OUTCOME, CXID, ZXID, MULTI_INDEX, AUTH_SCHEME } public enum Result { SUCCESS, FAILURE, INVOKED } -} + public enum Outcome { + COMMITTED, FAILED, ROLLED_BACK, UNKNOWN + } +} diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditHelper.java b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditHelper.java index ce0d58a87ba..47aaf1c760d 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditHelper.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/AuditHelper.java @@ -18,23 +18,41 @@ package org.apache.zookeeper.audit; import java.io.IOException; -import java.util.HashMap; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import org.apache.jute.Record; import org.apache.zookeeper.CreateMode; import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.KeeperException.Code; import org.apache.zookeeper.MultiOperationRecord; import org.apache.zookeeper.Op; import org.apache.zookeeper.ZKUtil; -import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.ZooDefs.OpCode; +import org.apache.zookeeper.audit.AuditEvent.Outcome; import org.apache.zookeeper.audit.AuditEvent.Result; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; import org.apache.zookeeper.proto.CreateRequest; +import org.apache.zookeeper.proto.CreateTTLRequest; import org.apache.zookeeper.proto.DeleteRequest; import org.apache.zookeeper.proto.SetACLRequest; import org.apache.zookeeper.proto.SetDataRequest; import org.apache.zookeeper.server.ByteBufferInputStream; import org.apache.zookeeper.server.DataTree.ProcessTxnResult; import org.apache.zookeeper.server.Request; +import org.apache.zookeeper.server.ServerCnxn; +import org.apache.zookeeper.server.auth.AuthenticationProvider; +import org.apache.zookeeper.server.auth.DigestAuthenticationProvider; +import org.apache.zookeeper.server.auth.IPAuthenticationProvider; +import org.apache.zookeeper.server.auth.ProviderRegistry; +import org.apache.zookeeper.server.auth.SASLAuthenticationProvider; +import org.apache.zookeeper.server.auth.X509AuthenticationProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,6 +61,64 @@ */ public final class AuditHelper { private static final Logger LOG = LoggerFactory.getLogger(AuditHelper.class); + private static final String REDACTED = "[redacted]"; + + /** + * Records the identities on a valid session attachment, including reconnects. + * The caller must have validated and assigned the session before invoking this hook. + */ + public static void addSessionEstablishedLog(ServerCnxn cnxn) { + logConnection(cnxn, AuditConstants.OP_SESSION_ESTABLISHED, null, Result.SUCCESS, null, null); + } + + /** + * Records an actual authentication outcome, not an intermediate SASL challenge. + * Success includes the accepted scheme's current bindings; failure does not infer a principal. + */ + public static void addAuthenticationLog(ServerCnxn cnxn, String scheme, Code code, int cxid) { + logConnection(cnxn, AuditConstants.OP_AUTHENTICATION, scheme, + code == Code.OK ? Result.SUCCESS : Result.FAILURE, code == null ? null : code.intValue(), cxid); + } + + private static void logConnection(ServerCnxn cnxn, String operation, String scheme, + Result result, Integer error, Integer cxid) { + try { + boolean enhanced = ZKAuditProvider.isEnhancedAuditEnabled(); + if (!enhanced) { + return; + } + long sessionId = cnxn.getSessionId(); + String session = sessionId == 0 ? null : "0x" + Long.toHexString(sessionId); + String ip = cnxn.getHostAddress(); + Map> bindings = connectionBindings(cnxn, operation, scheme, result); + for (Map.Entry> binding : bindings.entrySet()) { + for (String user : binding.getValue()) { + ZKAuditProvider.logConnection(user, operation, binding.getKey(), + session, ip, result, error, cxid, enhanced); + } + } + } catch (RuntimeException e) { + ZKAuditProvider.reportAuditError(LOG, "Failed to audit log operation {}", operation, e); + } + } + + private static Map> connectionBindings( + ServerCnxn cnxn, String operation, String scheme, Result result) { + boolean attachment = AuditConstants.OP_SESSION_ESTABLISHED.equals(operation); + Map> bindings = new LinkedHashMap<>(); + if (result == Result.SUCCESS) { + for (Id id : cnxn.getAuthInfo()) { + if (attachment || (id != null && scheme != null && scheme.equals(id.getScheme()))) { + bindings.computeIfAbsent(id == null ? null : id.getScheme(), key -> new LinkedHashSet<>()) + .add(safeUser(id)); + } + } + } + if (bindings.isEmpty()) { + bindings.put(scheme, Collections.singleton(null)); + } + return bindings; + } public static void addAuditLog(Request request, ProcessTxnResult rc) { addAuditLog(request, rc, false); @@ -53,174 +129,334 @@ public static void addAuditLog(Request request, ProcessTxnResult rc) { * * @param request user request * @param txnResult ProcessTxnResult - * @param failedTxn whether audit is being done failed transaction for normal transaction + * @param failedTxn whether the transaction was rejected before applying the requested operation */ public static void addAuditLog(Request request, ProcessTxnResult txnResult, boolean failedTxn) { if (!ZKAuditProvider.isAuditEnabled()) { return; } - String op = null; - //For failed transaction rc.path is null - String path = txnResult.path; - String acls = null; - String createMode = null; try { - switch (request.type) { - case ZooDefs.OpCode.create: - case ZooDefs.OpCode.create2: - case ZooDefs.OpCode.createContainer: - op = AuditConstants.OP_CREATE; - if (failedTxn) { - CreateRequest createRequest = new CreateRequest(); - deserialize(request, createRequest); - path = createRequest.getPath(); - createMode = - getCreateMode(createRequest); - } else { - createMode = getCreateMode(request); - } - break; - case ZooDefs.OpCode.delete: - case ZooDefs.OpCode.deleteContainer: - op = AuditConstants.OP_DELETE; - if (failedTxn) { - DeleteRequest deleteRequest = new DeleteRequest(); - deserialize(request, deleteRequest); - path = deleteRequest.getPath(); - } - break; - case ZooDefs.OpCode.setData: - op = AuditConstants.OP_SETDATA; - if (failedTxn) { - SetDataRequest setDataRequest = new SetDataRequest(); - deserialize(request, setDataRequest); - path = setDataRequest.getPath(); - } - break; - case ZooDefs.OpCode.setACL: - op = AuditConstants.OP_SETACL; - if (failedTxn) { - SetACLRequest setACLRequest = new SetACLRequest(); - deserialize(request, setACLRequest); - path = setACLRequest.getPath(); - acls = ZKUtil.aclToString(setACLRequest.getAcl()); - } else { - acls = getACLs(request); + if (request.type == OpCode.multi) { + logMultiOperation(request, txnResult, failedTxn); + return; + } + String operation = operationFor(request.type); + if (operation == null) { + return; + } + Integer error = errorCode(request, txnResult, failedTxn); + Outcome outcome = outcome(txnResult, failedTxn, error); + RequestMetadata metadata = new RequestMetadata(); + boolean enhanced = ZKAuditProvider.isEnhancedAuditEnabled(); + if (isCreate(request.type) || request.type == OpCode.setACL + || (request.type != OpCode.reconfig && (enhanced || outcome != Outcome.COMMITTED))) { + try { + metadata = metadata(request.type, readRequestRecord(request), enhanced); + } catch (IOException e) { + auditError(request.type, e); + } + } + String path = txnResult == null ? null : txnResult.path; + if (outcome != Outcome.COMMITTED || path == null) { + path = metadata.path == null ? path : metadata.path; + } + log(request, txnResult, path, operation, metadata, result(outcome), error, outcome, null, enhanced); + } catch (RuntimeException e) { + auditError(request.type, e); + } + } + + private static Record readRequestRecord(Request request) throws IOException { + Record record; + switch (request.type) { + case OpCode.create: + case OpCode.create2: + case OpCode.createContainer: + record = new CreateRequest(); + break; + case OpCode.createTTL: + record = new CreateTTLRequest(); + break; + case OpCode.delete: + case OpCode.deleteContainer: + record = new DeleteRequest(); + break; + case OpCode.setData: + record = new SetDataRequest(); + break; + case OpCode.setACL: + record = new SetACLRequest(); + break; + case OpCode.multi: + record = new MultiOperationRecord(); + break; + default: + throw new IOException("Unsupported audit request type: " + request.type); + } + if (request.request == null) { + throw new IOException("Audit request record is unavailable"); + } + ByteBuffer buffer = request.request.duplicate(); + buffer.rewind(); + ByteBufferInputStream.byteBuffer2Record(buffer, record); + return record; + } + + private static void logMultiOperation(Request request, ProcessTxnResult rc, boolean failedTxn) { + boolean enhanced = ZKAuditProvider.isEnhancedAuditEnabled(); + Integer error = errorCode(request, rc, failedTxn); + boolean failed = failedTxn || (error != null && error != Code.OK.intValue()); + int failureIndex = -1; + if (rc != null && rc.multiResult != null) { + for (int index = 0; index < rc.multiResult.size(); index++) { + ProcessTxnResult subResult = rc.multiResult.get(index); + if (subResult != null && (subResult.type == OpCode.error || subResult.err != Code.OK.intValue())) { + failed = true; + if (failureIndex < 0 && subResult.err != Code.OK.intValue()) { + failureIndex = index; } - break; - case ZooDefs.OpCode.multi: - if (failedTxn) { - op = AuditConstants.OP_MULTI_OP; - } else { - logMultiOperation(request, txnResult); - //operation si already logged - return; + if (error == null || error == Code.OK.intValue()) { + error = subResult.err; } + } + } + } + // Emit the failed parent before decoding so malformed metadata cannot hide the failure. + if (failed) { + log(request, rc, rc == null ? null : rc.path, AuditConstants.OP_MULTI_OP, + new RequestMetadata(), Result.FAILURE, error, Outcome.FAILED, null, enhanced); + if (!enhanced) { + return; + } + } + MultiOperationRecord multiRequest; + try { + multiRequest = (MultiOperationRecord) readRequestRecord(request); + } catch (IOException e) { + auditError(request.type, e); + return; + } + boolean complete = rc != null && rc.type == OpCode.multi && rc.multiResult != null + && rc.multiResult.size() == multiRequest.size(); + if (complete) { + int index = 0; + for (Op op : multiRequest) { + ProcessTxnResult subResult = rc.multiResult.get(index++); + if (subResult == null || (subResult.type != OpCode.error + && subResult.type != op.getType() + && !(subResult.type == OpCode.create2 && op.getType() == OpCode.create))) { + complete = false; break; - case ZooDefs.OpCode.reconfig: - op = AuditConstants.OP_RECONFIG; - break; - default: - //Not an audit log operation - return; + } } - Result result = getResult(txnResult, failedTxn); - log(request, path, op, acls, createMode, result); - } catch (Throwable e) { - LOG.error("Failed to audit log request {}", request.type, e); + } + if (!complete) { + auditError(request.type, new IOException("Audit multi request/result positions do not match")); + } + int index = 0; + for (Op op : multiRequest) { + ProcessTxnResult subResult = complete ? rc.multiResult.get(index) : null; + String operation = operationFor(op.getType()); + if (operation != null) { + Outcome outcome; + if (!complete) { + outcome = Outcome.UNKNOWN; + } else if (failed) { + // A zero-code error member was rolled back, not committed. Later members are not applied. + outcome = subResult.err == Code.OK.intValue() + || (index > failureIndex && subResult.err == Code.RUNTIMEINCONSISTENCY.intValue()) + ? Outcome.ROLLED_BACK : Outcome.FAILED; + } else { + outcome = Outcome.COMMITTED; + } + RequestMetadata metadata = metadata(op.getType(), op.toRequestRecord(), enhanced); + String path = outcome == Outcome.COMMITTED ? subResult.path : op.getPath(); + log(request, rc, path, operation, metadata, failed ? Result.FAILURE : result(outcome), + subResult == null ? null : subResult.err, outcome, index, enhanced); + } + index++; } } - private static void deserialize(Request request, Record record) throws IOException { - request.request.rewind(); - ByteBufferInputStream.byteBuffer2Record(request.request.slice(), record); + private static RequestMetadata metadata(int type, Record record, boolean enhanced) { + RequestMetadata metadata = new RequestMetadata(); + switch (type) { + case OpCode.create: + case OpCode.create2: + case OpCode.createContainer: + CreateRequest create = (CreateRequest) record; + metadata.path = create.getPath(); + metadata.dataLength = length(create.getData()); + metadata.createMode = createMode(type, create.getFlags()); + break; + case OpCode.createTTL: + CreateTTLRequest ttl = (CreateTTLRequest) record; + metadata.path = ttl.getPath(); + metadata.dataLength = length(ttl.getData()); + metadata.createMode = createMode(type, ttl.getFlags()); + break; + case OpCode.setData: + SetDataRequest setData = (SetDataRequest) record; + metadata.path = setData.getPath(); + metadata.dataLength = length(setData.getData()); + break; + case OpCode.delete: + case OpCode.deleteContainer: + metadata.path = ((DeleteRequest) record).getPath(); + break; + case OpCode.setACL: + SetACLRequest setAcl = (SetACLRequest) record; + metadata.path = setAcl.getPath(); + if (setAcl.getAcl() != null) { + metadata.acl = enhanced ? safeAclToString(setAcl.getAcl()) : ZKUtil.aclToString(setAcl.getAcl()); + } + break; + default: + break; + } + return metadata; } - private static Result getResult(ProcessTxnResult rc, boolean failedTxn) { - if (failedTxn) { - return Result.FAILURE; - } else { - return rc.err == KeeperException.Code.OK.intValue() ? Result.SUCCESS : Result.FAILURE; + private static String safeAclToString(List acls) { + StringBuilder value = new StringBuilder(); + for (ACL acl : acls) { + Id id = acl.getId(); + String user = REDACTED; + if ("world".equals(id.getScheme()) && "anyone".equals(id.getId())) { + user = "anyone"; + } else if ("digest".equals(id.getScheme())) { + AuthenticationProvider provider = ProviderRegistry.getProvider(id.getScheme()); + if (provider != null && provider.getClass() == DigestAuthenticationProvider.class + && id.getId() != null && provider.isValid(id.getId())) { + user = provider.getUserName(id.getId()); + } + } + value.append(id.getScheme()).append(':').append(user).append(':') + .append(ZKUtil.getPermString(acl.getPerms())); } + return value.toString(); } - private static void logMultiOperation(Request request, ProcessTxnResult rc) throws IOException, KeeperException { - Map createModes = AuditHelper.getCreateModes(request); - boolean multiFailed = false; - for (ProcessTxnResult subTxnResult : rc.multiResult) { - switch (subTxnResult.type) { - case ZooDefs.OpCode.create: - case ZooDefs.OpCode.create2: - case ZooDefs.OpCode.createTTL: - case ZooDefs.OpCode.createContainer: - log(request, subTxnResult.path, AuditConstants.OP_CREATE, null, - createModes.get(subTxnResult.path), Result.SUCCESS); - break; - case ZooDefs.OpCode.delete: - case ZooDefs.OpCode.deleteContainer: - log(request, subTxnResult.path, AuditConstants.OP_DELETE, null, - null, Result.SUCCESS); - break; - case ZooDefs.OpCode.setData: - log(request, subTxnResult.path, AuditConstants.OP_SETDATA, null, - null, Result.SUCCESS); - break; - case ZooDefs.OpCode.error: - multiFailed = true; - break; - default: - // Do nothing, it ok, we do not log all multi operations + private static String getUsers(Request request, boolean enhanced) { + if (!enhanced) { + return request.getUsers(); + } + if (request.authInfo == null) { + return null; + } + StringBuilder users = new StringBuilder(); + boolean first = true; + for (Id id : request.authInfo) { + if (!first) { + users.append(','); } + first = false; + users.append(safeUser(id)); } - if (multiFailed) { - log(request, rc.path, AuditConstants.OP_MULTI_OP, null, - null, Result.FAILURE); + return users.toString(); + } + + private static String safeUser(Id id) { + if (id == null || id.getScheme() == null || id.getId() == null) { + return REDACTED; + } + AuthenticationProvider provider = ProviderRegistry.getProvider(id.getScheme()); + if (provider == null) { + return REDACTED; } + Class providerClass = provider.getClass(); + // Custom providers, including subclasses, do not establish safe identity representations. + if ((providerClass == DigestAuthenticationProvider.class + || providerClass == IPAuthenticationProvider.class + || providerClass == SASLAuthenticationProvider.class + || providerClass == X509AuthenticationProvider.class) + && provider.isValid(id.getId())) { + return provider.getUserName(id.getId()); + } + return REDACTED; } - private static void log(Request request, String path, String op, String acls, String createMode, Result result) { - log(request.getUsers(), op, path, acls, createMode, - request.cnxn.getSessionIdHex(), request.cnxn.getHostAddress(), result); + private static String createMode(int type, int flags) { + try { + return CreateMode.fromFlag(flags).name().toLowerCase(Locale.ROOT); + } catch (KeeperException e) { + auditError(type, e); + return null; + } } - private static void log(String user, String operation, String znode, String acl, - String createMode, String session, String ip, Result result) { - ZKAuditProvider.log(user, operation, znode, acl, createMode, session, ip, result); + private static int length(byte[] data) { + return data == null ? 0 : data.length; } - private static String getACLs(Request request) throws IOException { - SetACLRequest setACLRequest = new SetACLRequest(); - deserialize(request, setACLRequest); - return ZKUtil.aclToString(setACLRequest.getAcl()); + private static Integer errorCode(Request request, ProcessTxnResult rc, boolean failedTxn) { + if (failedTxn && request.getException() != null) { + return request.getException().code().intValue(); + } + return rc == null || rc.type == 0 ? null : rc.err; } - private static String getCreateMode(Request request) throws IOException, KeeperException { - CreateRequest createRequest = new CreateRequest(); - deserialize(request, createRequest); - return getCreateMode(createRequest); + private static Outcome outcome(ProcessTxnResult rc, boolean failedTxn, Integer error) { + if (failedTxn || (rc != null && rc.type == OpCode.error) + || (error != null && error != Code.OK.intValue())) { + return Outcome.FAILED; + } + return error == null ? Outcome.UNKNOWN : Outcome.COMMITTED; } - private static String getCreateMode(CreateRequest createRequest) throws KeeperException { - return CreateMode.fromFlag(createRequest.getFlags()).toString().toLowerCase(); + private static Result result(Outcome outcome) { + return outcome == Outcome.COMMITTED ? Result.SUCCESS : outcome == Outcome.UNKNOWN ? Result.INVOKED : Result.FAILURE; } - private static Map getCreateModes(Request request) - throws IOException, KeeperException { - Map createModes = new HashMap<>(); - if (!ZKAuditProvider.isAuditEnabled()) { - return createModes; + private static String operationFor(int type) { + switch (type) { + case OpCode.create: + case OpCode.create2: + case OpCode.createTTL: + case OpCode.createContainer: + return AuditConstants.OP_CREATE; + case OpCode.delete: + case OpCode.deleteContainer: + return AuditConstants.OP_DELETE; + case OpCode.setData: + return AuditConstants.OP_SETDATA; + case OpCode.setACL: + return AuditConstants.OP_SETACL; + case OpCode.reconfig: + return AuditConstants.OP_RECONFIG; + default: + return null; } - MultiOperationRecord multiRequest = new MultiOperationRecord(); - deserialize(request, multiRequest); - for (Op op : multiRequest) { - if (op.getType() == ZooDefs.OpCode.create || op.getType() == ZooDefs.OpCode.create2 - || op.getType() == ZooDefs.OpCode.createContainer) { - CreateRequest requestRecord = (CreateRequest) op.toRequestRecord(); - createModes.put(requestRecord.getPath(), - getCreateMode(requestRecord)); - } + } + + private static boolean isCreate(int type) { + return type == OpCode.create || type == OpCode.create2 || type == OpCode.createTTL || type == OpCode.createContainer; + } + + private static void log(Request request, ProcessTxnResult rc, String path, String operation, + RequestMetadata metadata, Result result, Integer error, Outcome outcome, + Integer index, boolean enhanced) { + Long zxid = null; + if (request.getHdr() != null) { + zxid = request.getHdr().getZxid(); + } else if (rc != null && rc.type != 0) { + zxid = rc.zxid; + } else if (request.zxid >= 0) { + zxid = request.zxid; } - return createModes; + ZKAuditProvider.log(getUsers(request, enhanced), operation, path, metadata.acl, metadata.createMode, + request.cnxn.getSessionIdHex(), request.cnxn.getHostAddress(), result, + metadata.dataLength, error, outcome, request.cxid, zxid, index, enhanced); + } + + private static void auditError(int type, Exception e) { + ZKAuditProvider.reportAuditError(LOG, "Failed to audit log request {}", type, e); } + private static final class RequestMetadata { + private String path; + private String acl; + private String createMode; + private Integer dataLength; + } } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/ZKAuditProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/ZKAuditProvider.java index 864d2366c97..f76258b6b38 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/audit/ZKAuditProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/audit/ZKAuditProvider.java @@ -19,13 +19,17 @@ import static org.apache.zookeeper.audit.AuditEvent.FieldName; import java.lang.reflect.Constructor; +import java.util.Locale; +import org.apache.zookeeper.audit.AuditEvent.Outcome; import org.apache.zookeeper.audit.AuditEvent.Result; import org.apache.zookeeper.server.ServerCnxnFactory; +import org.apache.zookeeper.server.ServerMetrics; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ZKAuditProvider { static final String AUDIT_ENABLE = "zookeeper.audit.enable"; + static final String AUDIT_ENHANCED_ENABLE = "zookeeper.audit.enhanced.enable"; static final String AUDIT_IMPL_CLASS = "zookeeper.audit.impl.class"; private static final Logger LOG = LoggerFactory.getLogger(ZKAuditProvider.class); // By default audit logging is disabled @@ -66,9 +70,52 @@ public static boolean isAuditEnabled() { return auditEnabled; } + public static boolean isEnhancedAuditEnabled() { + return auditEnabled && Boolean.getBoolean(AUDIT_ENHANCED_ENABLE); + } + public static void log(String user, String operation, String znode, String acl, String createMode, String session, String ip, Result result) { - auditLogger.logAuditEvent(createLogEvent(user, operation, znode, acl, createMode, session, ip, result)); + log(user, operation, znode, acl, createMode, session, ip, result, null, null, null, null, null, null); + } + + /** + * Logs optional operation metadata only when enhanced audit logging is enabled. + * Null metadata is unavailable, not zero. Callers must sanitize ACL identities. + */ + public static void log(String user, String operation, String znode, String acl, + String createMode, String session, String ip, Result result, + Integer dataLength, Integer errorCode, Outcome outcome, + Integer cxid, Long zxid, Integer multiIndex) { + if (!isAuditEnabled()) { + return; + } + log(user, operation, znode, acl, createMode, session, ip, result, + dataLength, errorCode, outcome, cxid, zxid, multiIndex, isEnhancedAuditEnabled()); + } + + static void log(String user, String operation, String znode, String acl, + String createMode, String session, String ip, Result result, + Integer dataLength, Integer errorCode, Outcome outcome, + Integer cxid, Long zxid, Integer multiIndex, boolean enhanced) { + if (!isAuditEnabled()) { + return; + } + logAuditEvent(createLogEvent(user, operation, znode, acl, createMode, session, ip, result, + dataLength, errorCode, outcome, cxid, zxid, multiIndex, enhanced)); + } + + static void logConnection(String user, String operation, String authScheme, + String session, String ip, Result result, + Integer errorCode, Integer cxid, boolean enhanced) { + if (!isAuditEnabled() || !enhanced) { + return; + } + AuditEvent event = createLogEvent(user, operation, null, null, null, session, ip, result, + null, errorCode, result == Result.FAILURE ? Outcome.FAILED : Outcome.UNKNOWN, + cxid, null, null, enhanced); + event.addEntry(FieldName.AUTH_SCHEME, authScheme); + logAuditEvent(event); } /** @@ -78,6 +125,7 @@ static AuditEvent createLogEvent(String user, String operation, Result result) { AuditEvent event = new AuditEvent(result); event.addEntry(FieldName.USER, user); event.addEntry(FieldName.OPERATION, operation); + addMetadata(event, null, null, null, null, null, null, isEnhancedAuditEnabled()); return event; } @@ -86,6 +134,22 @@ static AuditEvent createLogEvent(String user, String operation, Result result) { */ static AuditEvent createLogEvent(String user, String operation, String znode, String acl, String createMode, String session, String ip, Result result) { + return createLogEvent(user, operation, znode, acl, createMode, session, ip, result, + null, null, null, null, null, null); + } + + static AuditEvent createLogEvent(String user, String operation, String znode, String acl, + String createMode, String session, String ip, Result result, + Integer dataLength, Integer errorCode, Outcome outcome, + Integer cxid, Long zxid, Integer multiIndex) { + return createLogEvent(user, operation, znode, acl, createMode, session, ip, result, + dataLength, errorCode, outcome, cxid, zxid, multiIndex, isEnhancedAuditEnabled()); + } + + private static AuditEvent createLogEvent(String user, String operation, String znode, String acl, + String createMode, String session, String ip, Result result, + Integer dataLength, Integer errorCode, Outcome outcome, + Integer cxid, Long zxid, Integer multiIndex, boolean enhanced) { AuditEvent event = new AuditEvent(result); event.addEntry(FieldName.SESSION, session); event.addEntry(FieldName.USER, user); @@ -94,9 +158,48 @@ static AuditEvent createLogEvent(String user, String operation, String znode, St event.addEntry(FieldName.ZNODE, znode); event.addEntry(FieldName.ZNODE_TYPE, createMode); event.addEntry(FieldName.ACL, acl); + addMetadata(event, dataLength, errorCode, outcome, cxid, zxid, multiIndex, enhanced); return event; } + private static void addMetadata(AuditEvent event, Integer dataLength, Integer errorCode, Outcome outcome, + Integer cxid, Long zxid, Integer multiIndex, boolean enhanced) { + if (enhanced) { + event.addEntry(FieldName.SCHEMA_VERSION, AuditConstants.SCHEMA_VERSION); + event.addEntry(FieldName.DATA_LENGTH, valueOf(dataLength)); + event.addEntry(FieldName.ERROR_CODE, valueOf(errorCode)); + event.addEntry(FieldName.OUTCOME, (outcome == null ? Outcome.UNKNOWN : outcome).name().toLowerCase(Locale.ROOT)); + event.addEntry(FieldName.CXID, valueOf(cxid)); + event.addEntry(FieldName.ZXID, valueOf(zxid)); + event.addEntry(FieldName.MULTI_INDEX, valueOf(multiIndex)); + } + } + + private static String valueOf(Number value) { + return value == null ? null : value.toString(); + } + + private static void logAuditEvent(AuditEvent event) { + try { + auditLogger.logAuditEvent(event); + } catch (RuntimeException e) { + reportAuditError(LOG, "Failed to write audit log for operation {}", event.getValue(FieldName.OPERATION), e); + } + } + + static void reportAuditError(Logger logger, String message, Object context, Exception error) { + try { + ServerMetrics.getMetrics().AUDIT_ERRORS.add(1); + } catch (RuntimeException ignored) { + // A failed metrics backend must not prevent the diagnostic or change the operation's result. + } + try { + logger.error(message, context, error); + } catch (RuntimeException ignored) { + // Reporting is best-effort; retrying through the same failing logger could escape or recurse. + } + } + /** * Add audit log for server start and register server stop log. */ @@ -119,7 +222,7 @@ public static void addServerStartFailureAuditLog() { } private static void log(String user, String operation, Result result) { - auditLogger.logAuditEvent(createLogEvent(user, operation, result)); + logAuditEvent(createLogEvent(user, operation, result)); } /** diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java index 1a5d1304120..82afce2f567 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java @@ -23,6 +23,7 @@ import java.io.PrintWriter; import java.io.Serializable; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -37,6 +38,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; import org.apache.jute.BinaryInputArchive; import org.apache.jute.BinaryOutputArchive; import org.apache.jute.InputArchive; @@ -59,6 +61,7 @@ import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooDefs.OpCode; import org.apache.zookeeper.audit.AuditConstants; +import org.apache.zookeeper.audit.AuditEvent.Outcome; import org.apache.zookeeper.audit.AuditEvent.Result; import org.apache.zookeeper.audit.ZKAuditProvider; import org.apache.zookeeper.common.PathTrie; @@ -106,6 +109,8 @@ public class DataTree { private static final Logger LOG = LoggerFactory.getLogger(DataTree.class); + private static final Pattern QUOTA_VALUE = Pattern.compile("count=-?[0-9]+,bytes=-?[0-9]+"); + private final RateLogger RATE_LOGGER = new RateLogger(LOG, 15 * 60 * 1000); /** @@ -760,6 +765,100 @@ public String getMaxPrefixWithQuota(String path) { } } + /** + * Samples only the exact namespace's existing quota metadata, without traversing + * or updating the tree. The two metadata reads are not an atomic snapshot. + * + * @param path a validated absolute, non-root, non-reserved namespace path + */ + public QuotaStats getQuotaStats(String path) { + DataNode namespace = getNode(path); + if (namespace == null) { + return new QuotaStats(null, null, "namespace_missing"); + } + String statPath = Quotas.statPath(path); + String limitPath = Quotas.quotaPath(path); + DataNode statNode = getNode(statPath); + DataNode limitNode = getNode(limitPath); + if (statNode == null && limitNode == null) { + return new QuotaStats(null, null, "quota_missing"); + } + if (statNode == null || limitNode == null) { + return new QuotaStats(null, null, "quota_incomplete"); + } + StatsTrack usage = readQuotaMetadata(statNode, 0); + StatsTrack limits = readQuotaMetadata(limitNode, -1); + if (getNode(path) != namespace || getNode(statPath) != statNode || getNode(limitPath) != limitNode) { + return new QuotaStats(null, null, "quota_changed"); + } + if (usage == null) { + return new QuotaStats(null, null, "invalid_quota_stats"); + } + if (limits == null) { + return new QuotaStats(null, null, "invalid_quota_limits"); + } + return new QuotaStats(usage, limits, null); + } + + private static StatsTrack readQuotaMetadata(DataNode node, int minimum) { + byte[] data = node.getData(); + if (data == null) { + return null; + } + String value = new String(data, StandardCharsets.UTF_8); + // StatsTrack accepts arbitrary field names; validate the metadata format first. + if (!QUOTA_VALUE.matcher(value).matches()) { + return null; + } + try { + StatsTrack stats = new StatsTrack(value); + return stats.getCount() < minimum || stats.getBytes() < minimum ? null : stats; + } catch (NumberFormatException e) { + return null; + } + } + + /** + * A quota metadata sample; unavailable samples expose no numeric values. + */ + public static final class QuotaStats { + + private final StatsTrack usage; + private final StatsTrack limits; + private final String reason; + + private QuotaStats(StatsTrack usage, StatsTrack limits, String reason) { + this.usage = usage; + this.limits = limits; + this.reason = reason; + } + + public Integer getCountUsed() { + return usage == null ? null : usage.getCount(); + } + + public Long getBytesUsed() { + return usage == null ? null : usage.getBytes(); + } + + public Integer getCountLimit() { + return limits == null || limits.getCount() == -1 ? null : limits.getCount(); + } + + public Long getBytesLimit() { + return limits == null || limits.getBytes() == -1 ? null : limits.getBytes(); + } + + public boolean isAvailable() { + return reason == null; + } + + public String getReason() { + return reason; + } + + } + public void addWatch(String basePath, Watcher watcher, int mode) { WatcherMode watcherMode = WatcherMode.fromZooDef(mode); dataWatches.addWatch(basePath, watcher, watcherMode); @@ -1451,15 +1550,11 @@ void deleteNodes(long session, long zxid, Iterable paths2Delete) { path, sessionHex); } if (ZKAuditProvider.isAuditEnabled()) { - if (deleted) { - ZKAuditProvider.log(ZKAuditProvider.getZKUser(), - AuditConstants.OP_DEL_EZNODE_EXP, path, null, null, - sessionHex, null, Result.SUCCESS); - } else { - ZKAuditProvider.log(ZKAuditProvider.getZKUser(), - AuditConstants.OP_DEL_EZNODE_EXP, path, null, null, - sessionHex, null, Result.FAILURE); - } + ZKAuditProvider.log(ZKAuditProvider.getZKUser(), + AuditConstants.OP_DEL_EZNODE_EXP, path, null, null, + sessionHex, null, deleted ? Result.SUCCESS : Result.FAILURE, + null, deleted ? Code.OK.intValue() : Code.NONODE.intValue(), + deleted ? Outcome.COMMITTED : Outcome.FAILED, null, zxid, null); } } } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java new file mode 100644 index 00000000000..ac2fbaebe0d --- /dev/null +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java @@ -0,0 +1,653 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zookeeper.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.jute.BinaryInputArchive; +import org.apache.zookeeper.Quotas; +import org.apache.zookeeper.StatsTrack; +import org.apache.zookeeper.Version; +import org.apache.zookeeper.ZKUtil; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.data.StatPersisted; +import org.apache.zookeeper.server.SnapshotComparer.SnapshotData; +import org.apache.zookeeper.server.persistence.SnapStream; +import org.apache.zookeeper.util.ServiceUtils; + +/** + * Streams non-value metadata from one validated snapshot. No transaction logs are + * replayed and neither the snapshot filename nor its digest establishes capture time + * or transaction-consistent state. See zookeeperTools.md for the versioned schema. + */ +public final class OfflineAuditExporter { + + private static final int SCHEMA_VERSION = 1; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern SNAPSHOT_NAME = Pattern.compile("snapshot\\.([0-9a-fA-F]{1,16})(?:\\.(?:gz|snappy))?"); + private static final Pattern QUOTA_VALUE = Pattern.compile("count=-?[0-9]+,bytes=-?[0-9]+"); + private static final Set ACL_SCHEMES = new HashSet<>(Arrays.asList("world", "auth", "digest", "ip", "sasl", "x509")); + + private OfflineAuditExporter() { + } + + public static void main(String[] args) { + Options options = new Options(); + options.addOption(Option.builder().longOpt("snapshot-file").hasArg().required().argName("SNAPSHOT") + .desc("Read only this snapshot file.").build()); + options.addOption(Option.builder().longOpt("output-dir").hasArg().required().argName("DIRECTORY") + .desc("Create a new directory; its parent must already exist.").build()); + options.addOption(Option.builder().longOpt("max-output-bytes").hasArg().argName("BYTES") + .desc("Optional positive byte budget, including the manifest. No default policy limit.").build()); + Path source; + Path output; + Long limit; + DecoderSettings decoder; + try { + CommandLine command = new DefaultParser().parse(options, args); + if (!command.getArgList().isEmpty()) { + throw new IllegalArgumentException("Unexpected positional arguments"); + } + Set supplied = new HashSet<>(); + for (Option option : command.getOptions()) { + if (!supplied.add(option.getLongOpt())) { + throw new IllegalArgumentException("Repeated option: --" + option.getLongOpt()); + } + } + limit = command.hasOption("max-output-bytes") + ? Long.valueOf(command.getOptionValue("max-output-bytes")) : null; + if (limit != null && limit <= 0) { + throw new IllegalArgumentException("max-output-bytes must be a positive 64-bit integer"); + } + decoder = DecoderSettings.read(); + source = Paths.get(command.getOptionValue("snapshot-file")).toAbsolutePath(); + String error = ZKUtil.validateFileInput(source.toString()); + if (error != null) { + throw new IllegalArgumentException(error); + } + output = Paths.get(command.getOptionValue("output-dir")).toAbsolutePath(); + if (Files.exists(output, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("Output already exists; no export attempted and prior files left untouched: " + output); + } + Path parent = output.getParent(); + if (parent == null || !Files.isDirectory(parent)) { + throw new IllegalArgumentException("Output parent must be an existing directory: " + output); + } + output = parent.toRealPath().resolve(fileName(output)); + } catch (ParseException | IllegalArgumentException | IOException e) { + System.err.println(e.getMessage()); + new HelpFormatter().printHelp("zkOfflineAudit.sh", options, true); + ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue()); + return; + } + try { + export(source, output, decoder, limit); + System.out.println("Snapshot-only export complete: " + output); + } catch (IOException | IllegalArgumentException | ArithmeticException e) { + System.err.println("Unable to export snapshot: " + e.getMessage()); + ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue()); + } + } + + /** + * Returns the entire successful getChildren/getChildren2 reply packet size, + * including the 16-byte ReplyHeader but excluding the outer four-byte frame. + */ + public static long childResponseBytes(Iterable children, boolean includeStat) { + long bytes = includeStat ? 88L : 20L; + for (String child : children) { + bytes = Math.addExact(bytes, 4L + child.getBytes(StandardCharsets.UTF_8).length); + } + return bytes; + } + + private static void export(Path source, Path output, DecoderSettings decoder, Long limit) throws IOException { + SourceIdentity identity = SourceIdentity.read(source); + SnapshotData snapshot = SnapshotComparer.readSnapshot(identity.path.toFile()); + if (output.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.createDirectory(output, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + } else { + Files.createDirectory(output); + } + OutputBudget budget = new OutputBudget(limit); + ExportRecords records = new ExportRecords(snapshot, decoder); + List> files = new ArrayList<>(); + try (RecordWriter nodes = new RecordWriter(output.resolve("nodes.ndjson"), budget); + RecordWriter namespaces = new RecordWriter(output.resolve("namespaces.ndjson"), budget); + RecordWriter sessions = new RecordWriter(output.resolve("sessions.ndjson"), budget)) { + records.write(nodes, namespaces, sessions); + files.add(nodes.finish()); + files.add(namespaces.finish()); + files.add(sessions.finish()); + } + + Map manifest = record(); + manifest.put("tool", fields("name", "OfflineAuditExporter", "version", Version.getFullVersion())); + manifest.put("export_id", UUID.randomUUID().toString()); + manifest.put("export_complete", true); + manifest.put("recovery_scope", "snapshot-only"); + manifest.put("transaction_logs_replayed", false); + manifest.put("source_capture_time_ms", null); + manifest.put("source_capture_provenance", null); + manifest.put("source_server_version", null); + manifest.put("source", identity.metadata(snapshot)); + manifest.put("decoder", decoder.metadata()); + manifest.put("snapshot_zxid", snapshotZxid(identity.path)); + manifest.put("last_observed_zxid", hex(records.lastObservedZxid)); + manifest.put("last_observed_zxid_scope", "maximum-node-czxid-mzxid-pzxid-unsigned"); + manifest.put("snapshot_digest", snapshot.digest == null ? null + : fields("zxid", hex(snapshot.digest.getZxid()), "version", snapshot.digest.getDigestVersion(), + "value", hex(snapshot.digest.getDigest()), "seal_validated", true, + "transaction_consistency_verified", false)); + manifest.put("unknown_owner_nodes", records.unknownOwnerNodes); + manifest.put("max_output_bytes", limit); + manifest.put("files", files); + Path pending = output.resolve(".manifest.json.inprogress"); + try (RecordWriter writer = new RecordWriter(pending, budget)) { + writer.write(manifest); + writer.finish(); + } + identity.verify(); + Files.move(pending, output.resolve("manifest.json"), StandardCopyOption.ATOMIC_MOVE); + } + + private enum NodeType { + PERSISTENT("persistent", "none"), + EPHEMERAL("ephemeral", "session"), + CONTAINER("container", "container"), + TTL("ttl", "ttl"), + TTL_353("ttl", "ttl-3.5.3"), + UNKNOWN("unknown", "unknown"); + + final String type; + final String encoding; + + NodeType(String type, String encoding) { + this.type = type; + this.encoding = encoding; + } + } + + private static final class DecoderSettings { + + final Boolean extended; + final Boolean emulate353; + final int maxBuffer; + final int extraBuffer; + + DecoderSettings(Boolean extended, Boolean emulate353, int maxBuffer, int extraBuffer) { + this.extended = extended; + this.emulate353 = emulate353; + this.maxBuffer = maxBuffer; + this.extraBuffer = extraBuffer; + } + + static DecoderSettings read() { + Boolean extended = booleanProperty(EphemeralType.EXTENDED_TYPES_ENABLED_PROPERTY).orElse(null); + Boolean emulate353 = booleanProperty(EphemeralType.TTL_3_5_3_EMULATION_PROPERTY).orElse(null); + if (Boolean.TRUE.equals(emulate353) && !Boolean.TRUE.equals(extended)) { + throw new IllegalArgumentException("emulate353TTLNodes=true requires explicit extendedTypesEnabled=true"); + } + String configuredMax = System.getProperty("jute.maxbuffer"); + if (configuredMax != null && Integer.decode(configuredMax) <= 0) { + throw new IllegalArgumentException("jute.maxbuffer must be a positive integer"); + } + int max = BinaryInputArchive.maxBuffer; + String configuredExtra = System.getProperty("zookeeper.jute.maxbuffer.extrasize"); + int extra = configuredExtra == null ? max : Integer.decode(configuredExtra); + if (extra < 0) { + throw new IllegalArgumentException("zookeeper.jute.maxbuffer.extrasize must not be negative"); + } + extra = Math.max(1024, extra); + if ((long) max + extra > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Combined Jute buffer limits exceed a signed 32-bit length"); + } + return new DecoderSettings(extended, emulate353, max, extra); + } + + NodeType type(long owner) { + if (owner == 0) { + return NodeType.PERSISTENT; + } + if (owner == EphemeralType.CONTAINER_EPHEMERAL_OWNER) { + return NodeType.CONTAINER; + } + // Negative encodings depend on source properties that are not stored in snapshots. + if (owner < 0 && (extended == null || (extended && emulate353 == null))) { + return NodeType.UNKNOWN; + } + if (EphemeralType.get(owner) == EphemeralType.TTL) { + return Boolean.TRUE.equals(emulate353) ? NodeType.TTL_353 : NodeType.TTL; + } + return NodeType.EPHEMERAL; + } + + Map metadata() { + return fields("source_extended_types_enabled", extended, "source_emulate_353_ttl_nodes", emulate353, + "effective_extended_types_enabled", Boolean.TRUE.equals(extended), + "effective_emulate_353_ttl_nodes", Boolean.TRUE.equals(emulate353), + "jute_maxbuffer", maxBuffer, "jute_extra_maxbuffer", extraBuffer); + } + + private static Optional booleanProperty(String name) { + String value = System.getProperty(name); + if (value == null) { + return Optional.empty(); + } + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { + throw new IllegalArgumentException(name + " must be true or false"); + } + return Optional.of(Boolean.valueOf(value)); + } + } + + private static final class ExportRecords { + + final SnapshotData snapshot; + final DecoderSettings decoder; + long lastObservedZxid; + long unknownOwnerNodes; + + ExportRecords(SnapshotData snapshot, DecoderSettings decoder) { + this.snapshot = snapshot; + this.decoder = decoder; + } + + void write(RecordWriter nodes, RecordWriter namespaces, RecordWriter sessions) throws IOException { + SnapshotRecursiveSummary.walk(snapshot.tree, "/", new SnapshotRecursiveSummary.TreeVisitor() { + @Override + public void visit(String path, DataNode node, int depth) throws IOException { + nodes.write(nodeRecord(path, node)); + } + + @Override + public void leave(String path, int depth, long count, long payloadBytes, long pathBytes) throws IOException { + if (depth == 0 && count != (long) snapshot.tree.getNodeCount() - 1) { + throw new IOException("Snapshot contains unreachable nodes or invalid root aliases"); + } + if (depth == 1 && !Quotas.procZookeeper.equals(path)) { + Map namespace = record(); + namespace.put("path", path); + namespace.put("subtree_node_count", count); + namespace.put("payload_bytes", payloadBytes); + namespace.put("path_utf8_bytes", pathBytes); + namespace.putAll(quota(path)); + namespaces.write(namespace); + } + } + }); + writeSessions(sessions); + } + + Map nodeRecord(String path, DataNode node) { + Map record = record(); + synchronized (node) { + StatPersisted stat = node.stat; + long owner = stat.getEphemeralOwner(); + NodeType type = decoder.type(owner); + if (type == NodeType.UNKNOWN) { + unknownOwnerNodes++; + } + observe(stat.getCzxid()); + observe(stat.getMzxid()); + observe(stat.getPzxid()); + long responseBytes = childResponseBytes(node.getChildren(), false); + record.put("path", path); + record.put("data_length", node.data == null ? 0L : (long) node.data.length); + record.put("num_children", (long) node.getChildren().size()); + record.put("getchildren_response_bytes", responseBytes); + record.put("getchildren2_response_bytes", Math.addExact(responseBytes, 68L)); + record.put("ctime_ms", stat.getCtime()); + record.put("mtime_ms", stat.getMtime()); + record.put("czxid", hex(stat.getCzxid())); + record.put("mzxid", hex(stat.getMzxid())); + record.put("pzxid", hex(stat.getPzxid())); + record.put("data_version", stat.getVersion()); + record.put("acl_version", stat.getAversion()); + record.put("persisted_cversion", stat.getCversion()); + record.put("path_utf8_bytes", (long) path.getBytes(StandardCharsets.UTF_8).length); + record.put("node_type", type.type); + record.put("ephemeral_owner", hex(owner)); + record.put("owner_encoding", type.encoding); + record.put("ttl_ms", type == NodeType.TTL || type == NodeType.TTL_353 + ? EphemeralType.TTL.getValue(owner) : null); + record.put("acl_risk_flags", aclFlags(snapshot.tree.getACL(node))); + } + return record; + } + + private void observe(long zxid) { + if (Long.compareUnsigned(zxid, lastObservedZxid) > 0) { + lastObservedZxid = zxid; + } + } + + private Map quota(String path) { + DataNode limit = snapshot.tree.getNode(Quotas.quotaPath(path)); + boolean statsPresent = snapshot.tree.getNode(Quotas.statPath(path)) != null; + String status = limit == null ? "absent" : "invalid"; + Integer count = null; + Long bytes = null; + String value = null; + if (limit != null) { + synchronized (limit) { + if (limit.data != null) { + value = new String(limit.data, StandardCharsets.UTF_8); + } + } + } + if (value != null && QUOTA_VALUE.matcher(value).matches()) { + try { + StatsTrack stats = new StatsTrack(value); + if (stats.getCount() >= -1 && stats.getBytes() >= -1) { + count = stats.getCount(); + bytes = stats.getBytes(); + status = "valid"; + } + } catch (NumberFormatException e) { + // Malformed quota metadata is a review flag, never a raw-value diagnostic. + status = "invalid"; + } + } + return fields("quota_status", status, "quota_limit_count", count, "quota_limit_bytes", bytes, + "quota_stat_present", statsPresent); + } + + private void writeSessions(RecordWriter writer) throws IOException { + for (long owner : snapshot.tree.getSessions()) { + if (decoder.type(owner) != NodeType.EPHEMERAL) { + continue; + } + Set paths = snapshot.tree.getEphemerals(owner); + long payload = 0; + long pathBytes = 0; + for (String path : paths) { + DataNode node = snapshot.tree.getNode(path); + if (node == null) { + throw new IOException("Missing ephemeral snapshot node: " + path); + } + synchronized (node) { + payload = Math.addExact(payload, node.data == null ? 0 : node.data.length); + } + pathBytes = Math.addExact(pathBytes, path.getBytes(StandardCharsets.UTF_8).length); + } + Map session = record(); + session.put("session_id", hex(owner)); + session.put("ephemeral_node_count", (long) paths.size()); + session.put("payload_bytes", payload); + session.put("path_utf8_bytes", pathBytes); + session.put("present_in_snapshot_session_table", snapshot.sessions.containsKey(owner)); + session.put("timeout_ms", snapshot.sessions.get(owner)); + writer.write(session); + } + } + } + + private static Set aclFlags(List acls) { + Set flags = new LinkedHashSet<>(); + if (acls == null || acls.isEmpty()) { + flags.add("missing_acl"); + return flags; + } + for (ACL acl : acls) { + Id id = acl.getId(); + int permissions = acl.getPerms(); + if ((permissions & ~ZooDefs.Perms.ALL) != 0) { + flags.add("invalid_permissions"); + } + if (id == null || id.getScheme() == null || id.getId() == null) { + flags.add("invalid_identity"); + continue; + } + if ("world".equals(id.getScheme()) && !"anyone".equals(id.getId())) { + flags.add("invalid_identity"); + } + if ("world".equals(id.getScheme()) && "anyone".equals(id.getId())) { + if ((permissions & ZooDefs.Perms.READ) != 0) { + flags.add("world_read"); + } + if ((permissions & (ZooDefs.Perms.WRITE | ZooDefs.Perms.CREATE | ZooDefs.Perms.DELETE)) != 0) { + flags.add("world_write"); + } + if ((permissions & ZooDefs.Perms.ADMIN) != 0) { + flags.add("world_admin"); + } + } + if ("auth".equals(id.getScheme())) { + flags.add("auth"); + } + if (!ACL_SCHEMES.contains(id.getScheme())) { + flags.add("unknown_scheme"); + } + } + return flags; + } + + private static final class SourceIdentity { + + final Path selected; + final Path path; + final BasicFileAttributes attributes; + final FileTime changeTime; + final String sha256; + + SourceIdentity(Path selected, Path path, BasicFileAttributes attributes, FileTime changeTime, String sha256) { + this.selected = selected; + this.path = path; + this.attributes = attributes; + this.changeTime = changeTime; + this.sha256 = sha256; + } + + static SourceIdentity read(Path selected) throws IOException { + Path path = selected.toRealPath(); + BasicFileAttributes before = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + FileTime changeTime = changeTime(path); + if (!before.isRegularFile()) { + throw new IOException("Snapshot must be a regular file: " + selected); + } + MessageDigest digest = sha256(); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[65536]; + int count; + while ((count = input.read(buffer)) != -1) { + digest.update(buffer, 0, count); + } + } + BasicFileAttributes after = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!path.equals(selected.toRealPath()) || !sameAttributes(before, after) + || !Objects.equals(changeTime, changeTime(path))) { + throw new IOException("Source changed while hashing: " + selected); + } + return new SourceIdentity(selected, path, before, changeTime, hexBytes(digest.digest())); + } + + void verify() throws IOException { + SourceIdentity current = read(selected); + if (!path.equals(current.path) || !sameAttributes(attributes, current.attributes) + || !Objects.equals(changeTime, current.changeTime) || !sha256.equals(current.sha256)) { + throw new IOException("Source changed during export: " + selected); + } + } + + Map metadata(SnapshotData snapshot) { + return fields("path", path.toString(), "sha256", sha256, "size_bytes", attributes.size(), + "mtime_ms", attributes.lastModifiedTime().toMillis(), + "change_time_ms", changeTime == null ? null : changeTime.toMillis(), + "compression", SnapStream.getStreamMode(fileName(path)).name().toLowerCase(Locale.ROOT), + "format_version", snapshot.header.getVersion(), "database_id", hex(snapshot.header.getDbid())); + } + + private static FileTime changeTime(Path path) throws IOException { + return path.getFileSystem().supportedFileAttributeViews().contains("unix") + ? (FileTime) Files.getAttribute(path, "unix:ctime", LinkOption.NOFOLLOW_LINKS) : null; + } + + private static boolean sameAttributes(BasicFileAttributes left, BasicFileAttributes right) { + return right.isRegularFile() && left.size() == right.size() + && left.lastModifiedTime().equals(right.lastModifiedTime()) + && left.creationTime().equals(right.creationTime()) + && Objects.equals(left.fileKey(), right.fileKey()); + } + } + + private static final class OutputBudget { + + final Long limit; + long written; + + OutputBudget(Long limit) { + this.limit = limit; + } + + void reserve(long count) throws IOException { + long next = Math.addExact(written, count); + if (limit != null && next > limit) { + throw new IOException("Output byte budget exceeded (max-output-bytes=" + limit + ")"); + } + written = next; + } + } + + private static final class RecordWriter implements Closeable { + + final Path path; + final OutputBudget budget; + final MessageDigest digest = sha256(); + final FileChannel channel; + final OutputStream output; + long records; + long bytes; + + RecordWriter(Path path, OutputBudget budget) throws IOException { + this.path = path; + this.budget = budget; + channel = FileChannel.open(path, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + output = new BufferedOutputStream(new DigestOutputStream(Channels.newOutputStream(channel), digest)); + } + + void write(Map record) throws IOException { + byte[] encoded = JSON.writeValueAsBytes(record); + long size = (long) encoded.length + 1; + budget.reserve(size); + output.write(encoded); + output.write('\n'); + bytes = Math.addExact(bytes, size); + records++; + } + + Map finish() throws IOException { + output.flush(); + channel.force(true); + return fields("name", fileName(path), "records", records, + "size_bytes", bytes, "sha256", hexBytes(digest.digest())); + } + + @Override + public void close() throws IOException { + output.close(); + } + } + + private static String snapshotZxid(Path source) { + Matcher matcher = SNAPSHOT_NAME.matcher(fileName(source)); + return matcher.matches() ? hex(Long.parseUnsignedLong(matcher.group(1), 16)) : null; + } + + private static String fileName(Path path) { + Path name = path.getFileName(); + if (name == null) { + throw new IllegalArgumentException("Path must identify a file: " + path); + } + return name.toString(); + } + + private static Map record() { + return fields("schema_version", SCHEMA_VERSION); + } + + private static Map fields(Object... fields) { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < fields.length; i += 2) { + result.put((String) fields[i], fields[i + 1]); + } + return result; + } + + private static String hex(long value) { + return String.format(Locale.ROOT, "0x%016x", value); + } + + private static String hexBytes(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(String.format(Locale.ROOT, "%02x", value & 0xff)); + } + return result.toString(); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("JVM does not provide SHA-256", e); + } + } +} diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerMetrics.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerMetrics.java index c9cfcc12a52..4b5ba818106 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerMetrics.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerMetrics.java @@ -227,6 +227,7 @@ private ServerMetrics(MetricsProvider metricsProvider) { STALE_REQUESTS = metricsContext.getCounter("stale_requests"); STALE_REQUESTS_DROPPED = metricsContext.getCounter("stale_requests_dropped"); STALE_REPLIES = metricsContext.getCounter("stale_replies"); + AUDIT_ERRORS = metricsContext.getCounter("audit_errors"); REQUEST_THROTTLE_WAIT_COUNT = metricsContext.getCounter("request_throttle_wait_count"); LARGE_REQUESTS_REJECTED = metricsContext.getCounter("large_requests_rejected"); @@ -443,6 +444,7 @@ private ServerMetrics(MetricsProvider metricsProvider) { public final Counter STALE_REQUESTS; public final Counter STALE_REQUESTS_DROPPED; public final Counter STALE_REPLIES; + public final Counter AUDIT_ERRORS; public final Counter REQUEST_THROTTLE_WAIT_COUNT; public final Counter LARGE_REQUESTS_REJECTED; diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotComparer.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotComparer.java new file mode 100644 index 00000000000..41d491744f5 --- /dev/null +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotComparer.java @@ -0,0 +1,493 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zookeeper.server; + +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.PushbackInputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.Set; +import java.util.zip.CheckedInputStream; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.jute.BinaryInputArchive; +import org.apache.jute.InputArchive; +import org.apache.jute.Record; +import org.apache.zookeeper.ZKUtil; +import org.apache.zookeeper.server.persistence.FileHeader; +import org.apache.zookeeper.server.persistence.FileSnap; +import org.apache.zookeeper.server.persistence.SnapStream; +import org.apache.zookeeper.server.util.SerializeUtils; +import org.apache.zookeeper.util.ServiceUtils; + +/** + * Compares snapshot subtree data sizes and descendant counts, including ephemeral nodes. + * Sessions, payload contents, ACLs and other metadata are not compared. Equal sizes and + * counts do not establish identical contents or transaction-consistent state. + * + *

Backported from Apache ZooKeeper commit f90060b83da4bfcca58ada93a57fedb40a069387 + * (ZOOKEEPER-3427). + */ +public class SnapshotComparer { + + private static final String LEFT_OPTION = "left"; + private static final String RIGHT_OPTION = "right"; + private static final String BYTE_THRESHOLD_OPTION = "bytes"; + private static final String NODE_THRESHOLD_OPTION = "nodes"; + private static final String DEBUG_OPTION = "debug"; + private static final String INTERACTIVE_OPTION = "interactive"; + + private final Options options = new Options(); + + private SnapshotComparer() { + options.addOption(Option.builder("l").longOpt(LEFT_OPTION).hasArg().required() + .argName("LEFT").desc("(Required) The left snapshot file.").build()); + options.addOption(Option.builder("r").longOpt(RIGHT_OPTION).hasArg().required() + .argName("RIGHT").desc("(Required) The right snapshot file.").build()); + options.addOption(Option.builder("b").longOpt(BYTE_THRESHOLD_OPTION).hasArg().required() + .argName("BYTETHRESHOLD") + .desc("(Required) The node data delta size threshold, in bytes, for printing the node.").build()); + options.addOption(Option.builder("n").longOpt(NODE_THRESHOLD_OPTION).hasArg().required() + .argName("NODETHRESHOLD") + .desc("(Required) The descendant node delta size threshold, in nodes, for printing the node.").build()); + options.addOption("d", DEBUG_OPTION, false, "Use debug output."); + options.addOption("i", INTERACTIVE_OPTION, false, "Enter interactive mode."); + } + + private void usage() { + new HelpFormatter().printHelp(120, "java -cp " + SnapshotComparer.class.getName(), "", options, ""); + } + + public static void main(String[] args) { + new SnapshotComparer().compareSnapshots(args); + } + + private void compareSnapshots(String[] args) { + CommandLine parsedOptions; + File left; + File right; + int byteThreshold; + int nodeThreshold; + try { + parsedOptions = new DefaultParser().parse(options, args); + if (!parsedOptions.getArgList().isEmpty()) { + throw new IllegalArgumentException("Unexpected arguments: " + parsedOptions.getArgList()); + } + byteThreshold = Integer.parseInt(parsedOptions.getOptionValue(BYTE_THRESHOLD_OPTION)); + nodeThreshold = Integer.parseInt(parsedOptions.getOptionValue(NODE_THRESHOLD_OPTION)); + if (byteThreshold < 0 || nodeThreshold < 0) { + throw new IllegalArgumentException("Thresholds must be non-negative integers."); + } + left = new File(parsedOptions.getOptionValue(LEFT_OPTION)); + right = new File(parsedOptions.getOptionValue(RIGHT_OPTION)); + for (File file : new File[]{left, right}) { + String error = ZKUtil.validateFileInput(file.toString()); + if (error != null) { + throw new IllegalArgumentException(error); + } + } + } catch (ParseException | IllegalArgumentException e) { + System.err.println(e.getMessage()); + usage(); + ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue()); + return; + } + + boolean debug = parsedOptions.hasOption(DEBUG_OPTION); + boolean interactive = parsedOptions.hasOption(INTERACTIVE_OPTION); + System.out.println("Successfully parsed options!"); + try { + TreeInfo leftTree = new TreeInfo(left); + TreeInfo rightTree = new TreeInfo(right); + System.out.println(leftTree); + System.out.println(rightTree); + compareTrees(leftTree, rightTree, byteThreshold, nodeThreshold, debug, interactive); + } catch (IOException e) { + System.err.println("Unable to read snapshot: " + e.getMessage()); + ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue()); + } + } + + private static class TreeInfo { + + private static class TreeNode { + + final String label; + final long size; + final List children = new ArrayList<>(); + long descendantSize; + long descendantCount; + + TreeNode(String label, long size) { + this.label = label; + this.size = size; + } + + void populateChildren(DataTree dataTree, TreeInfo treeInfo, int depth) { + DataNode node = dataTree.getNode(label); + Set childLabels; + synchronized (node) { + childLabels = node.getChildren(); + } + for (String childName : childLabels) { + String childPath = label + "/" + childName; + DataNode childNode = dataTree.getNode(childPath); + long childSize; + synchronized (childNode) { + childSize = childNode.data == null ? 0 : childNode.data.length; + } + TreeNode child = new TreeNode(childPath, childSize); + child.populateChildren(dataTree, treeInfo, depth + 1); + children.add(child); + } + descendantSize = size; + descendantCount = children.size(); + for (TreeNode child : children) { + descendantSize += child.descendantSize; + descendantCount += child.descendantCount; + } + treeInfo.registerNode(this, depth); + } + + } + + final TreeNode root; + long count; + final List> nodesAtDepths = new ArrayList<>(); + final Map nodesByName = new HashMap<>(); + + TreeInfo(File snapshot) throws IOException { + long beginning = System.nanoTime(); + DataTree dataTree = getSnapshot(snapshot); + System.out.printf("Deserialized snapshot in %s in %f seconds%n", + snapshot.getName(), (System.nanoTime() - beginning) / 1_000_000_000.0); + beginning = System.nanoTime(); + DataNode rootNode = dataTree.getNode(""); + long size; + synchronized (rootNode) { + size = rootNode.data == null ? 0 : rootNode.data.length; + } + root = new TreeNode("", size); + root.populateChildren(dataTree, this, 0); + System.out.printf("Processed data tree in %f seconds%n", (System.nanoTime() - beginning) / 1_000_000_000.0); + } + + void registerNode(TreeNode node, int depth) { + while (depth >= nodesAtDepths.size()) { + nodesAtDepths.add(new ArrayList<>()); + } + nodesAtDepths.get(depth).add(node); + nodesByName.put(node.label, node); + count++; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(String.format("Node count: %d%n", count)); + builder.append(String.format("Total size: %d%n", root.descendantSize)); + builder.append(String.format("Max depth: %d%n", nodesAtDepths.size())); + for (int i = 0; i < nodesAtDepths.size(); i++) { + builder.append(String.format("Count of nodes at depth %d: %d%n", i, nodesAtDepths.get(i).size())); + } + return builder.toString(); + } + + } + + static DataTree getSnapshot(File file) throws IOException { + return readSnapshot(file).tree; + } + + static final class SnapshotData { + + final DataTree tree; + final Map sessions; + final FileHeader header; + final DataTree.ZxidDigest digest; + + SnapshotData(DataTree tree, Map sessions, FileHeader header, DataTree.ZxidDigest digest) { + this.tree = tree; + this.sessions = sessions; + this.header = header; + this.digest = digest; + } + + } + + private static final class SnapshotInputArchive extends BinaryInputArchive { + + long nodeRecords; + + SnapshotInputArchive(InputStream input) { + super(new DataInputStream(input)); + } + + @Override + public void readRecord(Record record, String tag) throws IOException { + super.readRecord(record, tag); + if (record instanceof DataNode) { + nodeRecords++; + } + } + + } + + static SnapshotData readSnapshot(File file) throws IOException { + try (CheckedInputStream stream = SnapStream.getInputStream(file); + PushbackInputStream input = new PushbackInputStream(stream)) { + SnapshotInputArchive archive = new SnapshotInputArchive(input); + FileHeader header = new FileHeader(); + header.deserialize(archive, "fileheader"); + if (header.getMagic() != FileSnap.SNAP_MAGIC || header.getVersion() != 2) { + throw new IOException("Unsupported snapshot magic or format version: " + header.getVersion()); + } + DataTree dataTree = new DataTree(); + Map sessions = new HashMap<>(); + SerializeUtils.deserializeSnapshot(dataTree, archive, sessions); + if (dataTree.getNode("") == null || dataTree.getNode("") != dataTree.getNode("/")) { + throw new IOException("Invalid snapshot root"); + } + // DataTree overwrites duplicate paths; count records without a second path index. + if (archive.nodeRecords != (long) dataTree.getNodeCount() - 1) { + throw new IOException("Duplicate snapshot node records"); + } + checkSealIntegrity(stream, archive); + + // Distinguish an absent digest in older snapshots from a truncated digest. + DataTree.ZxidDigest digest = null; + int next = input.read(); + if (next != -1) { + input.unread(next); + digest = dataTree.new ZxidDigest(); + digest.deserialize(archive); + checkSealIntegrity(stream, archive); + if (input.read() != -1) { + throw new IOException("Unexpected data after snapshot"); + } + } + return new SnapshotData(dataTree, sessions, header, digest); + } catch (IOException | IllegalArgumentException e) { + throw new IOException(file + ": " + e, e); + } + } + + private static void checkSealIntegrity(CheckedInputStream stream, InputArchive archive) throws IOException { + // SnapStream's seal checker is package-private on this branch. + long checksum = stream.getChecksum().getValue(); + long expected = archive.readLong("val"); + String path = archive.readString("path"); + if (checksum != expected || !"/".equals(path)) { + throw new IOException("CRC corruption or invalid snapshot seal"); + } + } + + private static void printThresholdInfo(int byteThreshold, int nodeThreshold) { + System.out.printf("Printing analysis for nodes difference larger than %d bytes or node count difference larger than %d.%n", + byteThreshold, nodeThreshold); + } + + private static void compareTrees( + TreeInfo left, + TreeInfo right, + int byteThreshold, + int nodeThreshold, + boolean debug, + boolean interactive) { + int maxDepth = Math.max(left.nodesAtDepths.size(), right.nodesAtDepths.size()); + if (!interactive) { + printThresholdInfo(byteThreshold, nodeThreshold); + for (int i = 0; i < maxDepth; i++) { + System.out.printf("Analysis for depth %d%n", i); + compareLine(left, right, i, byteThreshold, nodeThreshold, debug, false); + } + } else { + try (Scanner scanner = new Scanner(System.in)) { + int currentDepth = 0; + while (currentDepth < maxDepth) { + System.out.printf("Current depth is %d%n", currentDepth); + System.out.println("- Press enter to move to print current depth layer;\n" + + "- Type a number to jump to and print all nodes at a given depth;\n" + + "- Enter an ABSOLUTE path to print the immediate subtree of a node. Path must start with '/'."); + if (!scanner.hasNextLine()) { + System.out.println("End of input."); + return; + } + String input = scanner.nextLine(); + printThresholdInfo(byteThreshold, nodeThreshold); + if (input.isEmpty()) { + System.out.printf("Analysis for depth %d%n", currentDepth); + compareLine(left, right, currentDepth, byteThreshold, nodeThreshold, debug, true); + currentDepth++; + } else if (input.startsWith("/")) { + System.out.printf("Analysis for node %s%n", input); + compareSubtree(left, right, input, byteThreshold, nodeThreshold, debug); + } else { + try { + int depth = Integer.parseInt(input); + if (depth < 0 || depth >= maxDepth) { + System.out.printf("Depth must be in range [%d, %d]%n", 0, maxDepth - 1); + continue; + } + currentDepth = depth; + System.out.printf("Analysis for depth %d%n", currentDepth); + compareLine(left, right, currentDepth, byteThreshold, nodeThreshold, debug, true); + } catch (NumberFormatException e) { + System.out.printf("Input %s is not valid. Depth must be in range [%d, %d]. " + + "Path must be an absolute path which starts with '/'.%n", + input, 0, maxDepth - 1); + } + } + System.out.println(); + } + } + } + System.out.println("All layers compared."); + } + + private static void compareSubtree( + TreeInfo left, + TreeInfo right, + String path, + int byteThreshold, + int nodeThreshold, + boolean debug) { + String label = "/".equals(path) ? "" : path; + TreeInfo.TreeNode leftRoot = left.nodesByName.get(label); + TreeInfo.TreeNode rightRoot = right.nodesByName.get(label); + if (leftRoot == null && rightRoot == null) { + System.out.printf("Path %s is neither found in left tree nor right tree.%n", path); + } else { + List leftList = leftRoot == null ? Collections.emptyList() : leftRoot.children; + List rightList = rightRoot == null ? Collections.emptyList() : rightRoot.children; + compareNodes(leftList, rightList, byteThreshold, nodeThreshold, debug, true); + } + } + + private static void compareLine( + TreeInfo left, + TreeInfo right, + int depth, + int byteThreshold, + int nodeThreshold, + boolean debug, + boolean interactive) { + List leftList = depth >= left.nodesAtDepths.size() + ? Collections.emptyList() : left.nodesAtDepths.get(depth); + List rightList = depth >= right.nodesAtDepths.size() + ? Collections.emptyList() : right.nodesAtDepths.get(depth); + compareNodes(leftList, rightList, byteThreshold, nodeThreshold, debug, interactive); + } + + private static void compareNodes( + List leftList, + List rightList, + int byteThreshold, + int nodeThreshold, + boolean debug, + boolean interactive) { + Comparator comparator = Comparator.comparing(node -> node.label); + Collections.sort(leftList, comparator); + Collections.sort(rightList, comparator); + int leftIndex = 0; + int rightIndex = 0; + while (leftIndex < leftList.size() || rightIndex < rightList.size()) { + TreeInfo.TreeNode leftNode = leftIndex < leftList.size() ? leftList.get(leftIndex) : null; + TreeInfo.TreeNode rightNode = rightIndex < rightList.size() ? rightList.get(rightIndex) : null; + if (leftNode != null && rightNode != null) { + if (debug) { + System.out.printf("Comparing %s to %s%n", leftNode.label, rightNode.label); + } + int result = comparator.compare(leftNode, rightNode); + if (result < 0) { + if (debug) { + System.out.println("left is less"); + } + printOnly(leftNode, "left", byteThreshold, nodeThreshold, debug, interactive); + leftIndex++; + } else if (result > 0) { + if (debug) { + System.out.println("right is less"); + } + printOnly(rightNode, "right", byteThreshold, nodeThreshold, debug, interactive); + rightIndex++; + } else { + if (debug) { + System.out.println("same"); + } + printBoth(leftNode, rightNode, byteThreshold, nodeThreshold, debug, interactive); + leftIndex++; + rightIndex++; + } + } else if (leftNode != null) { + printOnly(leftNode, "left", byteThreshold, nodeThreshold, debug, interactive); + leftIndex++; + } else { + printOnly(rightNode, "right", byteThreshold, nodeThreshold, debug, interactive); + rightIndex++; + } + } + } + + private static void printOnly( + TreeInfo.TreeNode node, + String side, + int byteThreshold, + int nodeThreshold, + boolean debug, + boolean interactive) { + if (node.descendantSize > byteThreshold || node.descendantCount > nodeThreshold) { + System.out.printf("Node %s found only in %s tree. Descendant size: %d. Descendant count: %d%n", + node.label, side, node.descendantSize, node.descendantCount); + } else if (debug || interactive) { + System.out.printf("Filtered %s node %s of size %d%n", side, node.label, node.descendantSize); + } + } + + private static void printBoth( + TreeInfo.TreeNode left, + TreeInfo.TreeNode right, + int byteThreshold, + int nodeThreshold, + boolean debug, + boolean interactive) { + long byteDelta = right.descendantSize - left.descendantSize; + long nodeDelta = right.descendantCount - left.descendantCount; + if (Math.abs(byteDelta) > byteThreshold || Math.abs(nodeDelta) > nodeThreshold) { + System.out.printf("Node %s found in both trees. Delta: %d bytes, %d descendants%n", + left.label, byteDelta, nodeDelta); + } else if (debug || interactive) { + System.out.printf("Filtered node %s of left size %d, right size %d%n", + left.label, left.descendantSize, right.descendantSize); + } + } + +} diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java new file mode 100644 index 00000000000..e0d1b06feac --- /dev/null +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zookeeper.server; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.TreeSet; +import org.apache.yetus.audience.InterfaceAudience; +import org.apache.zookeeper.ZKUtil; +import org.apache.zookeeper.common.PathUtils; +import org.apache.zookeeper.util.ServiceUtils; + +/** + * Recursively summarizes snapshot subtree data sizes and descendant counts. + * Only non-leaf nodes are printed, but totals include all descendants and ephemeral nodes. + * The maximum depth limits output, not traversal; zero means unlimited output depth. + * + *

Backported from Apache ZooKeeper commit 05b215994f5e145c2758c4089828b57ba471b329 + * (ZOOKEEPER-4566). + */ +@InterfaceAudience.Public +public class SnapshotRecursiveSummary { + + public static void main(String[] args) { + if (args.length != 3) { + System.err.println(getUsage()); + ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue()); + return; + } + try { + new SnapshotRecursiveSummary().run(args[0], args[1], Integer.parseInt(args[2])); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + System.err.println(getUsage()); + ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue()); + } catch (IOException e) { + System.err.println("Unable to read snapshot: " + e.getMessage()); + ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue()); + } + } + + public void run(String snapshotFileName, String startingNode, int maxDepth) throws IOException { + PathUtils.validatePath(startingNode); + if (maxDepth < 0) { + throw new IllegalArgumentException("max_depth must be a non-negative integer."); + } + String error = ZKUtil.validateFileInput(snapshotFileName); + if (error != null) { + throw new IllegalArgumentException(error); + } + DataTree dataTree = SnapshotComparer.getSnapshot(new File(snapshotFileName)); + if (dataTree.getNode(startingNode) == null) { + throw new IllegalArgumentException("Starting node does not exist: " + startingNode); + } + StringBuilder builder = new StringBuilder(); + Deque children = new ArrayDeque<>(); + walk(dataTree, startingNode, new TreeVisitor() { + @Override + public void visit(String path, DataNode node, int depth) { + children.push(new StringBuilder()); + } + + @Override + public void leave(String path, int depth, long nodes, long payloadBytes, long pathBytes) { + StringBuilder childSummary = children.pop(); + StringBuilder summary = new StringBuilder(); + if (nodes > 1 && (maxDepth == 0 || depth <= maxDepth)) { + String indent = String.join("", Collections.nCopies(depth, "--")); + summary.append(indent).append(" ").append(path).append("\n"); + summary.append(indent).append(" children: ").append(nodes - 1).append("\n"); + summary.append(indent).append(" data: ").append(payloadBytes).append("\n"); + summary.append(childSummary); + } + (children.isEmpty() ? builder : children.peek()).append(summary); + } + }); + System.out.println(builder); + } + + interface TreeVisitor { + + void visit(String path, DataNode node, int depth) throws IOException; + + void leave(String path, int depth, long nodes, long payloadBytes, long pathBytes) throws IOException; + + } + + private static final class Frame { + + final String path; + final int depth; + final Iterator children; + long nodes = 1; + long payloadBytes; + long pathBytes; + + Frame(String path, int depth, DataNode node) { + this.path = path; + this.depth = depth; + synchronized (node) { + children = new TreeSet<>(node.getChildren()).iterator(); + payloadBytes = node.data == null ? 0 : node.data.length; + } + pathBytes = path.getBytes(StandardCharsets.UTF_8).length; + } + + } + + /** Visits the offline tree once, with inclusive subtree totals on exit and no second tree. */ + static void walk(DataTree tree, String startingNode, TreeVisitor visitor) throws IOException { + String start = startingNode.isEmpty() ? "/" : startingNode; + Deque stack = new ArrayDeque<>(); + enter(tree, start, 0, visitor, stack); + while (!stack.isEmpty()) { + Frame current = stack.peek(); + if (current.children.hasNext()) { + String child = current.path + (current.path.equals("/") ? "" : "/") + current.children.next(); + enter(tree, child, current.depth + 1, visitor, stack); + } else { + stack.pop(); + visitor.leave(current.path, current.depth, current.nodes, current.payloadBytes, current.pathBytes); + if (!stack.isEmpty()) { + Frame parent = stack.peek(); + parent.nodes = Math.addExact(parent.nodes, current.nodes); + parent.payloadBytes = Math.addExact(parent.payloadBytes, current.payloadBytes); + parent.pathBytes = Math.addExact(parent.pathBytes, current.pathBytes); + } + } + } + } + + private static void enter(DataTree tree, String path, int depth, TreeVisitor visitor, Deque stack) + throws IOException { + PathUtils.validatePath(path); + DataNode node = tree.getNode(path); + if (node == null) { + throw new IOException("Missing snapshot node: " + path); + } + stack.push(new Frame(path, depth, node)); + visitor.visit(path, node, depth); + } + + public static String getUsage() { + String newLine = System.lineSeparator(); + return String.join(newLine, + "USAGE:", + "", + "SnapshotRecursiveSummary ", + "", + "snapshot_file: path to the zookeeper snapshot", + "starting_node: the absolute path in the zookeeper tree where traversal should begin", + "max_depth: non-negative output depth. 0 displays every non-leaf node; " + + "1 displays the starting node and its non-leaf children; 2 adds another level, and so on. " + + "This ONLY affects the level of details displayed, NOT the calculation."); + } + +} diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java index 2b2a5e5f569..09e4c70eb92 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java @@ -49,6 +49,7 @@ import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooDefs.OpCode; import org.apache.zookeeper.ZookeeperBanner; +import org.apache.zookeeper.audit.AuditHelper; import org.apache.zookeeper.common.Time; import org.apache.zookeeper.data.ACL; import org.apache.zookeeper.data.Id; @@ -1108,6 +1109,7 @@ public void finishSessionInit(ServerCnxn cnxn, boolean valid) { Long.toHexString(cnxn.getSessionId()), cnxn.getSessionTimeout(), cnxn.getRemoteSocketAddress()); + AuditHelper.addSessionEstablishedLog(cnxn); cnxn.enableRecv(); } else { @@ -1658,9 +1660,11 @@ public void processPacket(ServerCnxn cnxn, ByteBuffer incomingBuffer) throws IOE if (authReturn == KeeperException.Code.OK) { LOG.debug("Authentication succeeded for scheme: {}", scheme); LOG.info("auth success {}", cnxn.getRemoteSocketAddress()); + AuditHelper.addAuthenticationLog(cnxn, scheme, Code.OK, h.getXid()); ReplyHeader rh = new ReplyHeader(h.getXid(), 0, KeeperException.Code.OK.intValue()); cnxn.sendResponse(rh, null, null); } else { + AuditHelper.addAuthenticationLog(cnxn, scheme, Code.AUTHFAILED, h.getXid()); if (ap == null) { LOG.warn( "No authentication provider for scheme: {} has {}", @@ -1738,8 +1742,10 @@ private void processSasl(ByteBuffer incomingBuffer, ServerCnxn cnxn, RequestHead && authorizationID.equals(System.getProperty("zookeeper.superUser"))) { cnxn.addAuthInfo(new Id("super", "")); } + AuditHelper.addAuthenticationLog(cnxn, SASL_AUTH_SCHEME, Code.OK, requestHeader.getXid()); } } catch (SaslException e) { + AuditHelper.addAuthenticationLog(cnxn, SASL_AUTH_SCHEME, Code.AUTHFAILED, requestHeader.getXid()); LOG.warn("Client {} failed to SASL authenticate: {}", cnxn.getRemoteSocketAddress(), e); if (shouldAllowSaslFailedClientsConnect() && !shouldRequireClientSaslAuth()) { LOG.warn("Maintaining client connection despite SASL authentication failure."); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/admin/Commands.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/admin/Commands.java index 1476e5049b7..dc363b96676 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/admin/Commands.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/admin/Commands.java @@ -19,7 +19,11 @@ package org.apache.zookeeper.server.admin; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; import java.net.InetSocketAddress; import java.util.Arrays; import java.util.Collections; @@ -34,8 +38,11 @@ import java.util.stream.Collectors; import org.apache.zookeeper.Environment; import org.apache.zookeeper.Environment.Entry; +import org.apache.zookeeper.Quotas; import org.apache.zookeeper.Version; +import org.apache.zookeeper.common.PathUtils; import org.apache.zookeeper.server.DataTree; +import org.apache.zookeeper.server.DataTree.QuotaStats; import org.apache.zookeeper.server.ServerCnxnFactory; import org.apache.zookeeper.server.ServerMetrics; import org.apache.zookeeper.server.ZooKeeperServer; @@ -142,6 +149,7 @@ public static Command getCommand(String cmdName) { registerCommand(new LeaderCommand()); registerCommand(new MonitorCommand()); registerCommand(new ObserverCnxnStatResetCommand()); + registerCommand(new QuotaStatsCommand()); registerCommand(new RuokCommand()); registerCommand(new SetTraceMaskCommand()); registerCommand(new SrvrCommand()); @@ -480,6 +488,81 @@ public CommandResponse run(ZooKeeperServer zkServer, Map kwargs) } + /** + * Samples the existing quota metadata for one explicitly allowlisted namespace. + */ + public static class QuotaStatsCommand extends CommandBase { + + private static final String ALLOWED_NAMESPACES = "zookeeper.quotaStats.allowedNamespaces"; + private static final JsonFactory JSON = new JsonFactory(); + + public QuotaStatsCommand() { + super(Collections.singletonList("quota_stats")); + } + + @Override + public CommandResponse run(ZooKeeperServer zkServer, Map kwargs) { + String path = kwargs == null ? null : kwargs.get("path"); + if (!isValidNamespacePath(path)) { + return new CommandResponse(getPrimaryName(), "quota_stats requires a valid namespace path"); + } + final boolean allowed; + try { + allowed = isAllowed(path, System.getProperty(ALLOWED_NAMESPACES, "[]")); + } catch (IOException | IllegalArgumentException | SecurityException e) { + return new CommandResponse(getPrimaryName(), "Invalid " + ALLOWED_NAMESPACES + + ": expected a JSON array of valid namespace paths"); + } + if (!allowed) { + return new CommandResponse(getPrimaryName(), "Path is not allowlisted for quota_stats"); + } + + QuotaStats stats = zkServer.getZKDatabase().getDataTree().getQuotaStats(path); + CommandResponse response = initializeResponse(); + response.put("schema_version", 1); + response.put("path", path); + response.put("count_used", stats.getCountUsed()); + response.put("bytes_used", stats.getBytesUsed()); + response.put("count_limit", stats.getCountLimit()); + response.put("bytes_limit", stats.getBytesLimit()); + response.put("available", stats.isAvailable()); + response.put("reason", stats.getReason()); + return response; + } + + private static boolean isValidNamespacePath(String path) { + try { + PathUtils.validatePath(path); + } catch (IllegalArgumentException e) { + return false; + } + return !"/".equals(path) + && !Quotas.procZookeeper.equals(path) + && !path.startsWith(Quotas.procZookeeper + "/"); + } + + private static boolean isAllowed(String path, String configuration) throws IOException { + boolean allowed = false; + try (JsonParser parser = JSON.createParser(configuration)) { + if (parser.nextToken() != JsonToken.START_ARRAY) { + throw new IllegalArgumentException(); + } + JsonToken token; + while ((token = parser.nextToken()) != JsonToken.END_ARRAY) { + if (token != JsonToken.VALUE_STRING || !isValidNamespacePath(parser.getText())) { + throw new IllegalArgumentException(); + } + allowed |= path.equals(parser.getText()); + } + if (parser.nextToken() != null) { + throw new IllegalArgumentException(); + } + } + return allowed; + } + + } + /** * No-op command, check if the server is running */ diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditEventTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditEventTest.java index 02d9ac0bb85..d5f371a7a6a 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditEventTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditEventTest.java @@ -18,6 +18,8 @@ package org.apache.zookeeper.audit; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import java.util.Locale; import org.apache.zookeeper.audit.AuditEvent.Result; import org.junit.Test; @@ -42,4 +44,55 @@ public void testFormatShouldIgnoreKeyIfValueIsNull() { String expected = "operation=Value2\tresult=success"; assertEquals(expected, actual); } + + @Test + public void testProtocolNamesDoNotDependOnDefaultLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(new Locale("tr", "TR")); + AuditEvent event = new AuditEvent(Result.FAILURE); + event.addEntry(AuditEvent.FieldName.IP, "127.0.0.1"); + assertEquals("ip=127.0.0.1\tresult=failure", event.toString()); + assertEquals("result=invoked", new AuditEvent(Result.INVOKED).toString()); + } finally { + Locale.setDefault(previous); + } + } + + @Test + public void testEnhancedFormattingEscapesValuesReversibly() { + String previousAudit = System.getProperty(ZKAuditProvider.AUDIT_ENABLE); + String previousEnhanced = System.getProperty(AuditHelperTest.ENHANCED_ENABLE); + try { + System.setProperty(ZKAuditProvider.AUDIT_ENABLE, "true"); + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + AuditEvent event = ZKAuditProvider.createLogEvent("team\tname\r\n\\t", "setData", + "/name=value\\child", null, null, null, null, Result.FAILURE); + String log = event.toString(); + assertEquals("2", AuditHelperTest.fields(log).get("schema_version")); + assertEquals("team\\tname\\r\\n\\\\t", AuditHelperTest.fields(log).get("user")); + assertEquals("/name=value\\\\child", AuditHelperTest.fields(log).get("znode")); + assertEquals("team\tname\r\n\\t", event.getValue(AuditEvent.FieldName.USER)); + assertFalse(log.contains("\n")); + assertFalse(log.contains("\r")); + } finally { + AuditHelperTest.restoreProperty(ZKAuditProvider.AUDIT_ENABLE, previousAudit); + AuditHelperTest.restoreProperty(AuditHelperTest.ENHANCED_ENABLE, previousEnhanced); + } + } + + @Test + public void testLegacyValuesAreNotEscaped() { + AuditEvent event = new AuditEvent(Result.SUCCESS); + event.addEntry(AuditEvent.FieldName.USER, "team\tname\n\\t"); + assertEquals("user=team\tname\n\\t\tresult=success", event.toString()); + } + + @Test + public void testSchemaMarkerEscapesPreviouslyAddedValues() { + AuditEvent event = new AuditEvent(Result.SUCCESS); + event.addEntry(AuditEvent.FieldName.USER, "team\tname\n"); + event.addEntry(AuditEvent.FieldName.SCHEMA_VERSION, "2"); + assertEquals("user=team\\tname\\n\tschema_version=2\tresult=success", event.toString()); + } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditHelperTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditHelperTest.java new file mode 100644 index 00000000000..2dc2ab9f47a --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/AuditHelperTest.java @@ -0,0 +1,855 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.zookeeper.audit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.jute.BinaryOutputArchive; +import org.apache.jute.Record; +import org.apache.zookeeper.CreateMode; +import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.KeeperException.Code; +import org.apache.zookeeper.MultiOperationRecord; +import org.apache.zookeeper.Op; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.ZooDefs.OpCode; +import org.apache.zookeeper.audit.AuditEvent.Result; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.metrics.Counter; +import org.apache.zookeeper.metrics.MetricsUtils; +import org.apache.zookeeper.proto.CreateRequest; +import org.apache.zookeeper.proto.CreateTTLRequest; +import org.apache.zookeeper.proto.SetACLRequest; +import org.apache.zookeeper.proto.SetDataRequest; +import org.apache.zookeeper.server.DataTree; +import org.apache.zookeeper.server.DataTree.ProcessTxnResult; +import org.apache.zookeeper.server.Request; +import org.apache.zookeeper.server.ServerCnxn; +import org.apache.zookeeper.server.ServerMetrics; +import org.apache.zookeeper.server.auth.AuthenticationProvider; +import org.apache.zookeeper.server.auth.DigestAuthenticationProvider; +import org.apache.zookeeper.server.auth.ProviderRegistry; +import org.apache.zookeeper.txn.CheckVersionTxn; +import org.apache.zookeeper.txn.CloseSessionTxn; +import org.apache.zookeeper.txn.CreateTTLTxn; +import org.apache.zookeeper.txn.CreateTxn; +import org.apache.zookeeper.txn.DeleteTxn; +import org.apache.zookeeper.txn.ErrorTxn; +import org.apache.zookeeper.txn.MultiTxn; +import org.apache.zookeeper.txn.SetACLTxn; +import org.apache.zookeeper.txn.SetDataTxn; +import org.apache.zookeeper.txn.Txn; +import org.apache.zookeeper.txn.TxnHeader; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +public class AuditHelperTest { + static final String ENHANCED_ENABLE = "zookeeper.audit.enhanced.enable"; + private static final long SESSION = 0x123; + private AuditCapture capture; + private DataTree tree; + private ServerCnxn cnxn; + private String previousEnhanced; + private String previousAudit; + private String previousExtendedTypes; + + @Before + public void setUp() { + previousAudit = System.getProperty(ZKAuditProvider.AUDIT_ENABLE); + previousEnhanced = System.getProperty(ENHANCED_ENABLE); + previousExtendedTypes = System.getProperty("zookeeper.extendedTypesEnabled"); + System.setProperty(ZKAuditProvider.AUDIT_ENABLE, "true"); + System.setProperty(ENHANCED_ENABLE, "true"); + System.setProperty("zookeeper.extendedTypesEnabled", "true"); + assertTrue(ZKAuditProvider.isAuditEnabled()); + capture = new AuditCapture(); + tree = new DataTree(); + cnxn = mock(ServerCnxn.class); + when(cnxn.getSessionIdHex()).thenReturn("0x123"); + when(cnxn.getHostAddress()).thenReturn("127.0.0.1"); + } + + @After + public void tearDown() { + capture.close(); + restoreProperty(ZKAuditProvider.AUDIT_ENABLE, previousAudit); + restoreProperty(ENHANCED_ENABLE, previousEnhanced); + restoreProperty("zookeeper.extendedTypesEnabled", previousExtendedTypes); + } + + @Test + public void testCreateAndSetDataLengthsArePayloadBytes() throws Exception { + byte[][] data = {null, new byte[0], new byte[1], new byte[1024], + new byte[65536], new byte[1048575], "\u00e9\ud83d\ude00".getBytes(StandardCharsets.UTF_8)}; + String[] lengths = {"0", "0", "1", "1024", "65536", "1048575", "6"}; + for (int i = 0; i < data.length; i++) { + String path = "/length-" + i; + Request create = request(OpCode.create, createRecord(path, data[i], CreateMode.PERSISTENT)); + ProcessTxnResult created = apply(create, OpCode.create, createTxn(path, data[i], false)); + assertEquals(0, created.err); + AuditHelper.addAuditLog(create, created); + Map fields = fields(capture.read(1).get(0)); + assertWrite(fields, "create", path, lengths[i], "committed", "0"); + assertEquals("41", fields.get("cxid")); + assertEquals("66", fields.get("zxid")); + assertNull(fields.get("multi_index")); + + Request setData = request(OpCode.setData, new SetDataRequest(path, data[i], -1)); + ProcessTxnResult changed = apply(setData, OpCode.setData, new SetDataTxn(path, data[i], 1)); + assertEquals(0, changed.err); + AuditHelper.addAuditLog(setData, changed); + assertWrite(fields(capture.read(1).get(0)), "setData", path, lengths[i], "committed", "0"); + } + } + + @Test + public void testCreateTtlUsesTypedRecord() throws Exception { + byte[] data = {1, 2, 3}; + Request request = request(OpCode.createTTL, new CreateTTLRequest( + "/ttl", data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_WITH_TTL.toFlag(), 60000)); + ProcessTxnResult result = apply(request, OpCode.createTTL, + new CreateTTLTxn("/ttl", data, ZooDefs.Ids.OPEN_ACL_UNSAFE, -1, 60000)); + assertEquals(0, result.err); + AuditHelper.addAuditLog(request, result); + Map fields = fields(capture.read(1).get(0)); + assertWrite(fields, "create", "/ttl", "3", "committed", "0"); + assertEquals("persistent_with_ttl", fields.get("znode_type")); + } + + @Test + public void testFailureUsesAttemptedLengthAndReplyException() throws Exception { + Request request = request(OpCode.create, createRecord("/denied", new byte[7], CreateMode.PERSISTENT)); + ProcessTxnResult result = apply(request, OpCode.error, new ErrorTxn(Code.SESSIONEXPIRED.intValue())); + request.setException(KeeperException.create(Code.NOAUTH)); + AuditHelper.addAuditLog(request, result, true); + assertWrite(fields(capture.read(1).get(0)), "create", "/denied", "7", "failed", "-102"); + assertNull(tree.getNode("/denied")); + } + + @Test + public void testFailedMultiNeverCommitsZeroCodeErrorMembers() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Arrays.asList( + Op.create("/rolled", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.check("/", -1), + Op.setData("/missing", new byte[3], -1), + Op.create("/later", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Arrays.asList( + txn(OpCode.create, createTxn("/rolled", new byte[1], false)), + txn(OpCode.check, new CheckVersionTxn("/", -1)), + txn(OpCode.error, new ErrorTxn(Code.NONODE.intValue())), + txn(OpCode.error, new ErrorTxn(Code.RUNTIMEINCONSISTENCY.intValue()))))); + assertEquals(-101, result.err); + assertEquals(OpCode.error, result.multiResult.get(0).type); + assertEquals(0, result.multiResult.get(0).err); + assertNull(tree.getNode("/rolled")); + assertNull(tree.getNode("/later")); + + AuditHelper.addAuditLog(request, result); + List logs = capture.read(4); + assertWrite(fields(logs.get(0)), "multiOperation", null, null, "failed", "-101"); + assertWrite(fields(logs.get(1)), "create", "/rolled", "1", "rolled_back", "0"); + assertWrite(fields(logs.get(2)), "setData", "/missing", "3", "failed", "-101"); + assertWrite(fields(logs.get(3)), "create", "/later", "2", "rolled_back", "-2"); + assertEquals("0", fields(logs.get(1)).get("multi_index")); + assertEquals("2", fields(logs.get(2)).get("multi_index")); + assertEquals("3", fields(logs.get(3)).get("multi_index")); + for (String log : logs) { + assertFalse(log, log.contains("result=success")); + assertFalse(log, log.contains("outcome=committed")); + } + } + + @Test + public void testMultiMatchesRepeatedPathsAndTtlByPosition() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Arrays.asList( + Op.create("/same", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.delete("/same", -1), + Op.check("/", -1), + Op.create("/same", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL), + Op.create("/seq-", new byte[3], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_SEQUENTIAL_WITH_TTL, 60000)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Arrays.asList( + txn(OpCode.create, createTxn("/same", new byte[1], false)), + txn(OpCode.delete, new DeleteTxn("/same")), + txn(OpCode.check, new CheckVersionTxn("/", -1)), + txn(OpCode.create, createTxn("/same", new byte[2], true)), + txn(OpCode.createTTL, new CreateTTLTxn( + "/seq-0000000002", new byte[3], ZooDefs.Ids.OPEN_ACL_UNSAFE, -1, 60000))))); + assertEquals(0, result.err); + assertNotNull(tree.getNode("/seq-0000000002")); + + AuditHelper.addAuditLog(request, result); + List logs = capture.read(4); + assertWrite(fields(logs.get(0)), "create", "/same", "1", "committed", "0"); + assertEquals("persistent", fields(logs.get(0)).get("znode_type")); + assertEquals("ephemeral", fields(logs.get(2)).get("znode_type")); + assertEquals("3", fields(logs.get(2)).get("multi_index")); + assertWrite(fields(logs.get(3)), "create", "/seq-0000000002", "3", "committed", "0"); + assertEquals("persistent_sequential_with_ttl", fields(logs.get(3)).get("znode_type")); + assertEquals("4", fields(logs.get(3)).get("multi_index")); + } + + @Test + public void testIncompleteMultiResultsDoNotMisattributeCodes() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Arrays.asList( + Op.create("/incomplete", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.check("/", -1)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Arrays.asList( + txn(OpCode.create, createTxn("/incomplete", new byte[2], false)), + txn(OpCode.check, new CheckVersionTxn("/", -1))))); + assertEquals(0, result.err); + result.multiResult.remove(0); + long before = auditErrors(); + AuditHelper.addAuditLog(request, result); + assertWrite(fields(capture.read(1).get(0)), "create", "/incomplete", "2", "unknown", null); + assertEquals(before + 1, auditErrors()); + } + + @Test + public void testFirstRuntimeInconsistencyIsFailureNotSkippedMember() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Arrays.asList( + Op.create("/first", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.create("/skipped", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Arrays.asList( + txn(OpCode.error, new ErrorTxn(Code.RUNTIMEINCONSISTENCY.intValue())), + txn(OpCode.error, new ErrorTxn(Code.RUNTIMEINCONSISTENCY.intValue()))))); + assertNull(tree.getNode("/first")); + assertNull(tree.getNode("/skipped")); + AuditHelper.addAuditLog(request, result); + List logs = capture.read(3); + assertWrite(fields(logs.get(1)), "create", "/first", "1", "failed", "-2"); + assertWrite(fields(logs.get(2)), "create", "/skipped", "2", "rolled_back", "-2"); + } + + @Test + public void testDecodingPreservesPositionLimitAndMark() throws Exception { + for (int kind = 0; kind < 3; kind++) { + String path = "/buffer-" + kind; + byte[] bytes = serialize(createRecord(path, new byte[3], CreateMode.PERSISTENT)); + ByteBuffer storage = kind == 0 + ? ByteBuffer.allocateDirect(bytes.length + 12) : ByteBuffer.allocate(bytes.length + 12); + storage.position(4); + storage.put(bytes); + storage.position(4); + ByteBuffer buffer = storage.slice(); + buffer.limit(bytes.length); + if (kind == 2) { + buffer = buffer.asReadOnlyBuffer(); + } + buffer.position(2); + buffer.mark(); + buffer.position(bytes.length); + Request request = request(OpCode.create, buffer); + ProcessTxnResult result = apply(request, OpCode.create, createTxn(path, new byte[3], false)); + AuditHelper.addAuditLog(request, result); + assertEquals(bytes.length, buffer.position()); + assertEquals(bytes.length, buffer.limit()); + buffer.reset(); + assertEquals(2, buffer.position()); + assertWrite(fields(capture.read(1).get(0)), "create", path, "3", "committed", "0"); + } + } + + @Test + public void testMultiDecodingPreservesPositionLimitAndMark() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Collections.singletonList( + Op.create("/buffer-multi", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT)))); + request.request.position(3); + request.request.mark(); + int limit = request.request.limit(); + request.request.position(limit); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Collections.singletonList( + txn(OpCode.create, createTxn("/buffer-multi", new byte[2], false))))); + AuditHelper.addAuditLog(request, result); + assertEquals(limit, request.request.position()); + assertEquals(limit, request.request.limit()); + request.request.reset(); + assertEquals(3, request.request.position()); + assertWrite(fields(capture.read(1).get(0)), "create", "/buffer-multi", "2", "committed", "0"); + } + + @Test + public void testUnavailablePayloadIsOmittedAndDecodeErrorsAreCounted() throws Exception { + for (ByteBuffer buffer : Arrays.asList(null, ByteBuffer.wrap(new byte[] {0, 0, 0, 10, 1}))) { + String path = buffer == null ? "/unavailable" : "/undecodable"; + Request request = request(OpCode.create, buffer); + ProcessTxnResult result = apply(request, OpCode.create, createTxn(path, new byte[5], false)); + long before = auditErrors(); + AuditHelper.addAuditLog(request, result); + assertWrite(fields(capture.read(1).get(0)), "create", path, null, "committed", "0"); + assertEquals(before + 1, auditErrors()); + assertNotNull(tree.getNode(path)); + } + } + + @Test + public void testFailedMultiParentSurvivesUndecodableRequest() throws Exception { + Request request = request(OpCode.multi, ByteBuffer.wrap(new byte[] {1})); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Collections.singletonList( + txn(OpCode.error, new ErrorTxn(Code.NOAUTH.intValue()))))); + long before = auditErrors(); + AuditHelper.addAuditLog(request, result); + assertWrite(fields(capture.read(1).get(0)), "multiOperation", null, null, "failed", "-102"); + assertEquals(before + 1, auditErrors()); + } + + @Test + public void testMissingResultDoesNotInventSuccessOrZxid() throws Exception { + Request request = request(OpCode.setData, new SetDataRequest("/unknown", new byte[4], -1)); + try { + AuditHelper.addAuditLog(request, null); + } catch (RuntimeException e) { + fail("Unavailable transaction results must not escape the audit boundary: " + e); + } + Map fields = fields(capture.read(1).get(0)); + assertWrite(fields, "setData", "/unknown", "4", "unknown", null); + assertEquals("invoked", fields.get("result")); + assertNull(fields.get("zxid")); + } + + @Test + public void testEnhancedSetAclDoesNotExposeCredentials() throws Exception { + List acls = Arrays.asList( + new ACL(ZooDefs.Perms.ALL, new Id("world", "anyone")), + new ACL(ZooDefs.Perms.READ, new Id("digest", "alice:synthetic-digest-secret")), + new ACL(ZooDefs.Perms.WRITE, new Id("custom", "synthetic-token-secret")), + new ACL(ZooDefs.Perms.READ, new Id("x509", "-----BEGIN CERTIFICATE-----synthetic-body"))); + Request request = request(OpCode.setACL, new SetACLRequest("/acl", acls, -1)); + ProcessTxnResult result = apply(request, OpCode.error, new ErrorTxn(Code.INVALIDACL.intValue())); + AuditHelper.addAuditLog(request, result, true); + String log = capture.read(1).get(0); + assertWrite(fields(log), "setAcl", "/acl", null, "failed", "-114"); + assertTrue(log, log.contains("world:anyone:cdrwa")); + assertTrue(log, log.contains("digest:alice:r")); + assertFalse(log, log.contains("synthetic-digest-secret")); + assertFalse(log, log.contains("synthetic-token-secret")); + assertFalse(log, log.contains("BEGIN CERTIFICATE")); + assertFalse(log, log.contains("synthetic-body")); + } + + @Test + public void testMalformedDigestIdentityIsNotMistakenForAUser() throws Exception { + Request request = request(OpCode.setACL, new SetACLRequest("/acl", Collections.singletonList( + new ACL(ZooDefs.Perms.READ, new Id("digest", "synthetic-digest-token"))), -1)); + ProcessTxnResult result = apply(request, OpCode.error, new ErrorTxn(Code.INVALIDACL.intValue())); + AuditHelper.addAuditLog(request, result, true); + String log = capture.read(1).get(0); + assertFalse(log, log.contains("synthetic-digest-token")); + assertWrite(fields(log), "setAcl", "/acl", null, "failed", "-114"); + } + + @Test + public void testEnhancedUsersRedactRegisteredDefaultProvider() throws Exception { + String property = ProviderRegistry.AUTHPROVIDER_PROPERTY_PREFIX + "c1-audit-user"; + String previous = System.getProperty(property); + System.setProperty(property, CredentialAuthenticationProvider.class.getName()); + ProviderRegistry.initialize(); + try { + Request request = new Request(cnxn, SESSION, 41, OpCode.create, + ByteBuffer.wrap(serialize(createRecord("/custom-user", new byte[1], CreateMode.PERSISTENT))), + Arrays.asList(new Id("ip", "127.0.0.1"), + new Id("audit-test-custom", "alice:synthetic-password"))); + ProcessTxnResult result = apply(request, OpCode.create, createTxn("/custom-user", new byte[1], false)); + assertEquals(0, result.err); + AuditHelper.addAuditLog(request, result); + String log = capture.read(1).get(0); + assertWrite(fields(log), "create", "/custom-user", "1", "committed", "0"); + assertEquals("127.0.0.1,[redacted]", fields(log).get("user")); + assertFalse(log.contains("synthetic-password")); + } finally { + ProviderRegistry.removeProvider("audit-test-custom"); + restoreProperty(property, previous); + } + } + + @Test + public void testEnhancedUsersRedactUnknownAndMalformedIdentities() throws Exception { + Request request = new Request(cnxn, SESSION, 41, OpCode.create, + ByteBuffer.wrap(serialize(createRecord("/unknown-users", new byte[1], CreateMode.PERSISTENT))), + Arrays.asList(new Id("unregistered", "synthetic-token"), + new Id("digest", "synthetic-digest-token"), + new Id("ip", "alice:synthetic-password"))); + ProcessTxnResult result = apply(request, OpCode.create, createTxn("/unknown-users", new byte[1], false)); + AuditHelper.addAuditLog(request, result); + String log = capture.read(1).get(0); + assertWrite(fields(log), "create", "/unknown-users", "1", "committed", "0"); + assertEquals("[redacted],[redacted],[redacted]", fields(log).get("user")); + assertFalse(log.contains("synthetic")); + } + + @Test + public void testAclModeSnapshotSurvivesOffToOnInterleaving() throws Exception { + Request create = request(OpCode.create, createRecord("/mode-acl", new byte[0], CreateMode.PERSISTENT)); + assertEquals(0, apply(create, OpCode.create, createTxn("/mode-acl", new byte[0], false)).err); + String digest = DigestAuthenticationProvider.generateDigest("alice:synthetic-password"); + List acls = Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("digest", digest))); + SetACLRequest record = new SetACLRequest("/mode-acl", acls, -1); + AtomicInteger transitions = new AtomicInteger(); + Request switching = new Request(cnxn, SESSION, 41, OpCode.setACL, ByteBuffer.wrap(serialize(record)), + Collections.singletonList(new Id("ip", "127.0.0.1"))) { + @Override + public String getUsers() { + System.setProperty(ENHANCED_ENABLE, "true"); + transitions.incrementAndGet(); + return super.getUsers(); + } + }; + ProcessTxnResult changed = apply(switching, OpCode.setACL, new SetACLTxn("/mode-acl", acls, 1)); + assertEquals(0, changed.err); + System.setProperty(ENHANCED_ENABLE, "false"); + AuditHelper.addAuditLog(switching, changed); + Map legacy = fields(capture.read(1).get(0)); + assertEquals(1, transitions.get()); + assertEquals("true", System.getProperty(ENHANCED_ENABLE)); + assertNull("An in-flight legacy record must not be relabeled v2", legacy.get("schema_version")); + assertEquals("digest:" + digest + ":cdrwa", legacy.get("acl")); + assertEquals("success", legacy.get("result")); + + Request enhanced = request(OpCode.setACL, record); + ProcessTxnResult updated = apply(enhanced, OpCode.setACL, new SetACLTxn("/mode-acl", acls, 2)); + assertEquals(0, updated.err); + AuditHelper.addAuditLog(enhanced, updated); + String log = capture.read(1).get(0); + assertWrite(fields(log), "setAcl", "/mode-acl", null, "committed", "0"); + assertEquals("digest:alice:cdrwa", fields(log).get("acl")); + assertFalse(log.contains(digest)); + assertFalse(log.contains("synthetic-password")); + } + + @Test + public void testSuccessfulMultiKeepsOneModeAcrossMembers() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Arrays.asList( + Op.create("/mode-multi", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.setData("/mode-multi", new byte[2], -1)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Arrays.asList( + txn(OpCode.create, createTxn("/mode-multi", new byte[1], false)), + txn(OpCode.setData, new SetDataTxn("/mode-multi", new byte[2], 1))))); + assertEquals(0, result.err); + System.setProperty(ENHANCED_ENABLE, "false"); + AtomicInteger events = new AtomicInteger(); + AuditLogger delegate = new Slf4jAuditLogger(); + Object previousLogger = replaceProviderField("auditLogger", (AuditLogger) event -> { + delegate.logAuditEvent(event); + if (events.incrementAndGet() == 1) { + System.setProperty(ENHANCED_ENABLE, "true"); + } + }); + try { + AuditHelper.addAuditLog(request, result); + List logs = capture.read(2); + assertEquals(2, events.get()); + for (String log : logs) { + assertNull("A multi must keep its captured legacy mode", fields(log).get("schema_version")); + assertEquals("success", fields(log).get("result")); + } + + Request following = request(OpCode.setData, new SetDataRequest("/mode-multi", new byte[3], -1)); + ProcessTxnResult updated = apply(following, OpCode.setData, new SetDataTxn("/mode-multi", new byte[3], 2)); + assertEquals(0, updated.err); + AuditHelper.addAuditLog(following, updated); + assertWrite(fields(capture.read(1).get(0)), "setData", "/mode-multi", "3", "committed", "0"); + } finally { + replaceProviderField("auditLogger", previousLogger); + } + } + + @Test + public void testFailedMultiKeepsParentModeForRolledBackMembers() throws Exception { + Request request = request(OpCode.multi, new MultiOperationRecord(Arrays.asList( + Op.create("/mode-rolled", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.check("/missing", -1), + Op.setData("/mode-rolled", new byte[2], -1)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Arrays.asList( + txn(OpCode.create, createTxn("/mode-rolled", new byte[1], false)), + txn(OpCode.error, new ErrorTxn(Code.NONODE.intValue())), + txn(OpCode.error, new ErrorTxn(Code.RUNTIMEINCONSISTENCY.intValue()))))); + assertEquals(-101, result.err); + assertNull(tree.getNode("/mode-rolled")); + AtomicInteger events = new AtomicInteger(); + AuditLogger delegate = new Slf4jAuditLogger(); + Object previousLogger = replaceProviderField("auditLogger", (AuditLogger) event -> { + delegate.logAuditEvent(event); + if (events.incrementAndGet() == 1) { + System.setProperty(ENHANCED_ENABLE, "false"); + } + }); + try { + AuditHelper.addAuditLog(request, result); + List logs = capture.read(3); + assertEquals(3, events.get()); + assertWrite(fields(logs.get(0)), "multiOperation", null, null, "failed", "-101"); + assertWrite(fields(logs.get(1)), "create", "/mode-rolled", "1", "rolled_back", "0"); + assertWrite(fields(logs.get(2)), "setData", "/mode-rolled", "2", "rolled_back", "-2"); + assertEquals("2", fields(logs.get(2)).get("multi_index")); + } finally { + replaceProviderField("auditLogger", previousLogger); + } + } + + @Test + public void testAuditDisabledSkipsRequestsAndProvider() throws Exception { + Object previous = replaceProviderField("auditEnabled", false); + long before = auditErrors(); + try { + AuditHelper.addAuditLog(null, null); + ZKAuditProvider.log("user", "create", "/disabled", null, null, null, null, Result.SUCCESS); + capture.read(0); + assertEquals(before, auditErrors()); + } finally { + replaceProviderField("auditEnabled", previous); + } + } + + @Test + public void testDefaultAndExplicitLegacyOutputAreUnchanged() throws Exception { + for (String setting : Arrays.asList(null, "false")) { + restoreProperty(ENHANCED_ENABLE, setting); + ZKAuditProvider.log("user", "create", "/legacy", null, "persistent", "0x123", + "127.0.0.1", Result.SUCCESS); + assertEquals("session=0x123\tuser=user\tip=127.0.0.1\toperation=create" + + "\tznode=/legacy\tznode_type=persistent\tresult=success", capture.read(1).get(0)); + } + } + + @Test + public void testLegacyFailedMultiStillHasOnlyParent() throws Exception { + System.clearProperty(ENHANCED_ENABLE); + Request request = request(OpCode.multi, new MultiOperationRecord(Collections.singletonList( + Op.setData("/absent", new byte[2], -1)))); + ProcessTxnResult result = apply(request, OpCode.multi, new MultiTxn(Collections.singletonList( + txn(OpCode.error, new ErrorTxn(Code.NONODE.intValue()))))); + AuditHelper.addAuditLog(request, result); + Map fields = fields(capture.read(1).get(0)); + assertEquals("multiOperation", fields.get("operation")); + assertEquals("failure", fields.get("result")); + assertNull(fields.get("schema_version")); + } + + @Test + public void testReadsRemainUnaudited() { + for (int type : new int[] {OpCode.getData, OpCode.getChildren, OpCode.exists, OpCode.multiRead}) { + AuditHelper.addAuditLog(request(type, ByteBuffer.wrap(new byte[] {1})), new ProcessTxnResult()); + } + capture.read(0); + } + + @Test + public void testSystemDeletionHasStableIdentityAndSystemActor() throws Exception { + Request create = request(OpCode.create, createRecord("/ephemeral", new byte[0], CreateMode.EPHEMERAL)); + assertEquals(0, apply(create, OpCode.create, createTxn("/ephemeral", new byte[0], true)).err); + tree.processTxn(new TxnHeader(SESSION, -11, 80, 1000, OpCode.closeSession), + new CloseSessionTxn(Collections.singletonList("/ephemeral"))); + Map fields = fields(capture.read(1).get(0)); + assertWrite(fields, "ephemeralZNodeDeletionOnSessionCloseOrExpire", + "/ephemeral", null, "committed", "0"); + assertEquals(ZKAuditProvider.getZKUser(), fields.get("user")); + assertEquals("0x123", fields.get("session")); + assertEquals("80", fields.get("zxid")); + assertNull(fields.get("cxid")); + assertNull(fields.get("ip")); + assertNull(tree.getNode("/ephemeral")); + } + + @Test + public void testLoggerFailureDoesNotInterruptSystemDeletion() throws Exception { + for (String path : Arrays.asList("/ephemeral-a", "/ephemeral-b")) { + Request request = request(OpCode.create, createRecord(path, new byte[0], CreateMode.EPHEMERAL)); + assertEquals(0, apply(request, OpCode.create, createTxn(path, new byte[0], true)).err); + } + AuditLogger failingLogger = event -> { + throw new IllegalStateException("synthetic audit sink failure"); + }; + Object previous = replaceProviderField("auditLogger", failingLogger); + long before = auditErrors(); + try { + try { + tree.processTxn(new TxnHeader(SESSION, -11, 81, 1000, OpCode.closeSession), + new CloseSessionTxn(Arrays.asList("/ephemeral-a", "/ephemeral-b"))); + } catch (RuntimeException e) { + fail("Audit sink failure must not interrupt committed deletion: " + e); + } + assertNull(tree.getNode("/ephemeral-a")); + assertNull(tree.getNode("/ephemeral-b")); + assertEquals(before + 2, auditErrors()); + } finally { + replaceProviderField("auditLogger", previous); + } + } + + @Test + public void testFailingErrorCounterDoesNotEscapeMetadataFailure() throws Exception { + Request request = request(OpCode.create, ByteBuffer.wrap(new byte[] {1})); + ProcessTxnResult result = apply(request, OpCode.create, createTxn("/reporter-metadata", new byte[2], false)); + FailingCounter counter = new FailingCounter(); + Counter previousCounter = replaceAuditErrorCounter(counter); + try { + RuntimeException escaped = null; + try { + AuditHelper.addAuditLog(request, result); + } catch (RuntimeException e) { + escaped = e; + } + assertNotNull(tree.getNode("/reporter-metadata")); + assertNull("The error reporter must not escape the audit boundary", escaped); + assertWrite(fields(capture.read(1).get(0)), "create", "/reporter-metadata", null, "committed", "0"); + assertEquals("Do not retry a failing error reporter", 1, counter.get()); + } finally { + replaceAuditErrorCounter(previousCounter); + } + } + + @Test + public void testFailingErrorCounterDoesNotInterruptSystemDeletions() throws Exception { + for (String path : Arrays.asList("/reporter-a", "/reporter-b")) { + Request request = request(OpCode.create, createRecord(path, new byte[0], CreateMode.EPHEMERAL)); + assertEquals(0, apply(request, OpCode.create, createTxn(path, new byte[0], true)).err); + } + FailingCounter counter = new FailingCounter(); + Counter previousCounter = replaceAuditErrorCounter(counter); + Object previousLogger = replaceProviderField("auditLogger", (AuditLogger) event -> { + throw new IllegalStateException("synthetic audit sink failure"); + }); + try { + RuntimeException escaped = null; + try { + tree.processTxn(new TxnHeader(SESSION, -11, 81, 1000, OpCode.closeSession), + new CloseSessionTxn(Arrays.asList("/reporter-a", "/reporter-b"))); + } catch (RuntimeException e) { + escaped = e; + } + assertNull("Reporting a sink failure must not escape system deletion", escaped); + assertNull(tree.getNode("/reporter-a")); + assertNull(tree.getNode("/reporter-b")); + assertEquals("One best-effort report per deletion, with no retries", 2, counter.get()); + } finally { + replaceProviderField("auditLogger", previousLogger); + replaceAuditErrorCounter(previousCounter); + } + } + + private Request request(int type, Record record) throws IOException { + return request(type, ByteBuffer.wrap(serialize(record))); + } + + private Request request(int type, ByteBuffer buffer) { + return new Request(cnxn, SESSION, 41, type, buffer, + Collections.singletonList(new Id("ip", "127.0.0.1"))); + } + + private ProcessTxnResult apply(Request request, int type, Record txn) { + TxnHeader header = new TxnHeader(SESSION, 41, 66, 1000, type); + request.setHdr(header); + request.setTxn(txn); + return tree.processTxn(header, txn); + } + + private static CreateRequest createRecord(String path, byte[] data, CreateMode mode) { + return new CreateRequest(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, mode.toFlag()); + } + + private static CreateTxn createTxn(String path, byte[] data, boolean ephemeral) { + return new CreateTxn(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, ephemeral, -1); + } + + private static Txn txn(int type, Record record) throws IOException { + return new Txn(type, serialize(record)); + } + + private static byte[] serialize(Record record) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + record.serialize(BinaryOutputArchive.getArchive(out), "request"); + return out.toByteArray(); + } + + static long auditErrors() { + return ((Number) MetricsUtils.currentServerMetrics().getOrDefault("audit_errors", 0L)).longValue(); + } + + static Object replaceProviderField(String name, Object value) throws ReflectiveOperationException { + Field field = ZKAuditProvider.class.getDeclaredField(name); + field.setAccessible(true); + Object previous = field.get(null); + field.set(null, value); + return previous; + } + + static Counter replaceAuditErrorCounter(Counter counter) throws ReflectiveOperationException { + ServerMetrics metrics = ServerMetrics.getMetrics(); + Field field = ServerMetrics.class.getField("AUDIT_ERRORS"); + field.setAccessible(true); + Counter previous = (Counter) field.get(metrics); + field.set(metrics, counter); + return previous; + } + + static void restoreProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + + static Map fields(String log) { + Map fields = new LinkedHashMap<>(); + for (String pair : log.split("\t")) { + int separator = pair.indexOf('='); + assertTrue(log, separator > 0); + String previous = fields.put(pair.substring(0, separator), pair.substring(separator + 1)); + assertNull("Duplicate audit field in " + log, previous); + } + return fields; + } + + static void assertWrite(Map fields, String operation, String path, + String length, String outcome, String error) { + assertEquals("2", fields.get("schema_version")); + assertEquals(operation, fields.get("operation")); + assertEquals(path, fields.get("znode")); + assertEquals(length, fields.get("data_length")); + assertEquals(outcome, fields.get("outcome")); + assertEquals(error, fields.get("error_code")); + assertEquals("committed".equals(outcome) ? "success" : "unknown".equals(outcome) ? "invoked" : "failure", + fields.get("result")); + } + + public static class CredentialAuthenticationProvider implements AuthenticationProvider { + @Override + public String getScheme() { + return "audit-test-custom"; + } + + @Override + public Code handleAuthentication(ServerCnxn connection, byte[] authData) { + connection.addAuthInfo(new Id(getScheme(), new String(authData, StandardCharsets.UTF_8))); + return Code.OK; + } + + @Override + public boolean matches(String id, String aclExpr) { + return id.equals(aclExpr); + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public boolean isValid(String id) { + return true; + } + } + + static final class FailingCounter implements Counter { + private final AtomicInteger attempts = new AtomicInteger(); + + @Override + public void add(long delta) { + attempts.incrementAndGet(); + throw new IllegalStateException("synthetic audit counter failure"); + } + + @Override + public long get() { + return attempts.get(); + } + } + + static final class AuditCapture extends AppenderBase implements AutoCloseable { + private final Logger logger = (Logger) LoggerFactory.getLogger(Slf4jAuditLogger.class); + private final Level previousLevel = logger.getLevel(); + private final List messages = new ArrayList<>(); + private boolean overflow; + + AuditCapture() { + setContext(logger.getLoggerContext()); + logger.setLevel(Level.INFO); + logger.addAppender(this); + start(); + } + + @Override + protected synchronized void append(ILoggingEvent event) { + if (messages.size() < 128) { + messages.add(event.getFormattedMessage()); + } else { + overflow = true; + } + notifyAll(); + } + + synchronized List read(int expected) { + assertFalse("Audit capture exceeded its bounded capacity", overflow); + List result = new ArrayList<>(messages); + messages.clear(); + assertEquals(result.toString(), expected, result.size()); + return result; + } + + synchronized void clear() { + assertFalse("Audit capture exceeded its bounded capacity", overflow); + messages.clear(); + } + + synchronized List await(int expected, long timeoutMillis) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + while (messages.size() < expected) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + break; + } + TimeUnit.NANOSECONDS.timedWait(this, remaining); + } + return read(expected); + } + + @Override + public void close() { + logger.detachAppender(this); + logger.setLevel(previousLevel); + stop(); + } + } +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/SessionAuthAuditTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/SessionAuthAuditTest.java new file mode 100644 index 00000000000..c1f89d4118f --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/SessionAuthAuditTest.java @@ -0,0 +1,953 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.zookeeper.audit; + +import static org.apache.zookeeper.audit.AuditHelperTest.fields; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.X509TrustManager; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.AuthorizeCallback; +import javax.security.sasl.RealmCallback; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslClient; +import javax.security.sasl.SaslServer; +import org.apache.jute.BinaryOutputArchive; +import org.apache.jute.Record; +import org.apache.zookeeper.CreateMode; +import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.KeeperException.Code; +import org.apache.zookeeper.TestableZooKeeper; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.ZooDefs.OpCode; +import org.apache.zookeeper.ZooKeeper; +import org.apache.zookeeper.audit.AuditHelperTest.AuditCapture; +import org.apache.zookeeper.audit.AuditHelperTest.CredentialAuthenticationProvider; +import org.apache.zookeeper.audit.AuditHelperTest.FailingCounter; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.data.Stat; +import org.apache.zookeeper.metrics.Counter; +import org.apache.zookeeper.proto.AuthPacket; +import org.apache.zookeeper.proto.GetSASLRequest; +import org.apache.zookeeper.proto.ReplyHeader; +import org.apache.zookeeper.proto.RequestHeader; +import org.apache.zookeeper.proto.SetSASLResponse; +import org.apache.zookeeper.server.MockServerCnxn; +import org.apache.zookeeper.server.ServerCnxn; +import org.apache.zookeeper.server.ZooKeeperSaslServer; +import org.apache.zookeeper.server.ZooKeeperServer; +import org.apache.zookeeper.server.auth.DigestAuthenticationProvider; +import org.apache.zookeeper.server.auth.ProviderRegistry; +import org.apache.zookeeper.server.auth.SASLAuthenticationProvider; +import org.apache.zookeeper.server.auth.X509AuthenticationProvider; +import org.apache.zookeeper.test.ClientBase; +import org.apache.zookeeper.test.QuorumUtil; +import org.apache.zookeeper.test.X509AuthTest.TestCertificate; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +public class SessionAuthAuditTest extends ZKTestCase { + private static final long SESSION = 0x123; + private static final int AUTH_XID = -4; + private static final int SASL_XID = -33; + private static final String SECRET = "SYNTHETIC_C2_PASSWORD"; + private static final String TOKEN = "SYNTHETIC_C2_TOKEN"; + private static final String CERTIFICATE = "SYNTHETIC_C2_CERTIFICATE_BODY"; + private static final String PROVIDER_PREFIX = "zookeeper.authProvider.auditC2"; + private final Map previousProperties = new LinkedHashMap<>(); + private final List captured = new ArrayList<>(); + private AuditCapture capture; + private ZooKeeperServer server; + private Object previousAuditEnabled; + private Object previousAuditLogger; + + @Before + public void setUp() throws Exception { + property(ZKAuditProvider.AUDIT_ENABLE, "true"); + property(AuditHelperTest.ENHANCED_ENABLE, "true"); + property(ZooKeeperServer.ALLOW_SASL_FAILED_CLIENTS, "false"); + property(ZooKeeperServer.SESSION_REQUIRE_CLIENT_SASL_AUTH, "false"); + property(PROVIDER_PREFIX + "Sasl", SASLAuthenticationProvider.class.getName()); + property(PROVIDER_PREFIX + "X509", X509AuthenticationProvider.class.getName()); + property(PROVIDER_PREFIX + "Custom", CredentialAuthenticationProvider.class.getName()); + property(PROVIDER_PREFIX + "Empty", EmptyAuthenticationProvider.class.getName()); + ProviderRegistry.reset(); + ProviderRegistry.initialize(); + previousAuditEnabled = AuditHelperTest.replaceProviderField("auditEnabled", true); + previousAuditLogger = AuditHelperTest.replaceProviderField("auditLogger", new Slf4jAuditLogger()); + capture = new AuditCapture(); + server = new ZooKeeperServer(); + } + + @After + public void tearDown() throws Exception { + try { + for (String log : captured) { + assertFalse(log, log.contains(SECRET)); + assertFalse(log, log.contains(TOKEN)); + assertFalse(log, log.contains(CERTIFICATE)); + assertFalse(log, log.contains(DigestAuthenticationProvider.generateDigest("alice:" + SECRET))); + } + } finally { + capture.close(); + AuditHelperTest.replaceProviderField("auditLogger", previousAuditLogger); + AuditHelperTest.replaceProviderField("auditEnabled", previousAuditEnabled); + previousProperties.forEach(AuditHelperTest::restoreProperty); + ProviderRegistry.reset(); + } + } + + @Test + public void testAnonymousAttachmentDoesNotClaimAuthenticationOrNewSession() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + server.finishSessionInit(connection, true); + String first = read(1).get(0); + assertBindingEvent(first, "sessionEstablished", "0x123", "success", null, "unknown", null); + assertBindings(first, new String[][]{{null, null}}); + + server.finishSessionInit(connection, true); + String reconnect = read(1).get(0); + assertBindingEvent(reconnect, "sessionEstablished", "0x123", "success", null, "unknown", null); + assertBindings(reconnect, new String[][]{{null, null}}); + } + + @Test + public void testRejectedAttachmentDoesNotBindAnUnvalidatedSessionId() { + server.finishSessionInit(new RecordingCnxn(SESSION), false); + read(0); + } + + @Test + public void testX509IdentityBeforeAssignmentIsBoundWithoutClaimingTls() throws Exception { + RecordingCnxn connection = new RecordingCnxn(0); + X509Certificate certificate = new TestCertificate("CLIENT,OU=Audit") { + @Override + public byte[] getEncoded() { + return CERTIFICATE.getBytes(StandardCharsets.UTF_8); + } + }; + connection.clientChain = new X509Certificate[]{certificate}; + X509AuthenticationProvider provider = new X509AuthenticationProvider(mock(X509TrustManager.class), null); + assertEquals(Code.OK, provider.handleAuthentication(connection, null)); + assertEquals(0, connection.getSessionId()); + read(0); + + connection.sessionId = SESSION; + connection.addAuthInfo(new Id("super", TOKEN)); + server.finishSessionInit(connection, true); + List logs = read(2); + assertBindings(logs, new String[][]{{"x509", "CN=CLIENT,OU=Audit"}, {"super", "[redacted]"}}); + assertFalse(connection.isSecure()); + for (String log : logs) { + assertBindingEvent(log, "sessionEstablished", "0x123", "success", null, "unknown", null); + assertNull(fields(log).get("tls")); + assertNull(fields(log).get("secure")); + } + } + + @Test + public void testMultipleSchemesRemainPairedAndUntrustedIdentitiesAreRedacted() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("ip", "127.0.0.1")); + connection.addAuthInfo(new Id("digest", DigestAuthenticationProvider.generateDigest("alice:" + SECRET))); + connection.addAuthInfo(new Id("sasl", "service/host@EXAMPLE.COM")); + connection.addAuthInfo(new Id("x509", "CN=Bob,OU=Audit")); + connection.addAuthInfo(new Id("x509", "-----BEGIN CERTIFICATE-----\n" + CERTIFICATE)); + connection.addAuthInfo(new Id("audit-test-custom", "alice:" + SECRET)); + connection.addAuthInfo(new Id("missing", TOKEN)); + connection.addAuthInfo(new Id("digest", TOKEN)); + server.finishSessionInit(connection, true); + List logs = read(8); + assertBindings(logs, new String[][]{ + {"ip", "127.0.0.1"}, {"digest", "alice"}, {"sasl", "service/host@EXAMPLE.COM"}, + {"x509", "CN=Bob,OU=Audit"}, {"audit-test-custom", "[redacted]"}, + {"x509", "[redacted]"}, + {"missing", "[redacted]"}, {"digest", "[redacted]"}}); + for (String log : logs) { + assertBindingEvent(log, "sessionEstablished", "0x123", "success", null, "unknown", null); + } + } + + @Test + public void testExplicitAuthRecordsOnlyTheAcceptedSchemeAndNotCredentials() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("ip", "127.0.0.1")); + authenticate(connection, "digest", "alice:" + SECRET); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", "0x123", "success", "0", "unknown", "-4"); + assertBindings(log, new String[][]{{"digest", "alice"}}); + assertTrue(connection.getAuthInfo().contains( + new Id("digest", DigestAuthenticationProvider.generateDigest("alice:" + SECRET)))); + } + + @Test + public void testPreSessionAuthFailureHasNoInventedSessionOrPrincipal() throws Exception { + RecordingCnxn connection = new RecordingCnxn(0); + authenticate(connection, "missing", TOKEN); + assertEquals(Code.AUTHFAILED.intValue(), connection.lastReply().getErr()); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", null, "failure", "-115", "failed", "-4"); + assertBindings(log, new String[][]{{"missing", null}}); + } + + @Test + public void testAuthFailureDoesNotBlameAPreviouslyAttachedPrincipal() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("sasl", "previous@EXAMPLE.COM")); + authenticate(connection, "sasl", TOKEN); + assertEquals(Code.AUTHFAILED.intValue(), connection.lastReply().getErr()); + String log = read(1).get(0); + assertBindings(log, new String[][]{{"sasl", null}}); + assertFalse(log.contains("previous@EXAMPLE.COM")); + } + + @Test + public void testAcceptedAuthWithoutAnIdentityRetainsUnknownPrincipal() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("ip", "127.0.0.1")); + authenticate(connection, "audit-c2-empty", TOKEN); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", "0x123", "success", "0", "unknown", "-4"); + assertBindings(log, new String[][]{{"audit-c2-empty", null}}); + } + + @Test + public void testScalarUserRoundTripsDelimitersThroughRealSlf4jFormatting() throws Exception { + String username = "a,b=\"c\\d\t\r\n"; + RecordingCnxn connection = new RecordingCnxn(SESSION); + authenticate(connection, "digest", username + ":" + SECRET); + String log = read(1).get(0); + assertBindings(log, new String[][]{{"digest", "a,b=\"c\\d\t\r\n"}}); + assertFalse(log.contains("\n")); + assertFalse(log.contains("\r")); + assertEquals(10, fields(log).size()); + } + + @Test + public void testAuthSchemeUsesTheExistingVersionedEscaping() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + authenticate(connection, "missing,scheme=\"value\\t\t\r\n", TOKEN); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", "0x123", "failure", "-115", "failed", "-4"); + assertBindings(log, new String[][]{{"missing,scheme=\"value\\t\t\r\n", null}}); + assertFalse(log.contains("\n")); + assertFalse(log.contains("\r")); + assertFalse(fields(log).containsKey("user")); + } + + @Test + public void testRepeatedSanitizedPrincipalIsBoundOncePerAuthOutcome() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + authenticate(connection, "digest", "alice:" + SECRET); + assertBindings(read(1).get(0), new String[][]{{"digest", "alice"}}); + authenticate(connection, "digest", "alice:" + TOKEN); + assertEquals(2, connection.getAuthInfo().size()); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", "0x123", "success", "0", "unknown", "-4"); + assertBindings(log, new String[][]{{"digest", "alice"}}); + } + + @Test + public void testRedactedIdentitiesDoNotProduceDuplicateBindings() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("missing", SECRET)); + connection.addAuthInfo(new Id("missing", TOKEN)); + server.finishSessionInit(connection, true); + assertBindings(read(1).get(0), new String[][]{{"missing", "[redacted]"}}); + } + + @Test + public void testOneModeSnapshotCoversAllIdentityBindings() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("ip", "127.0.0.1")); + connection.addAuthInfo(new Id("digest", DigestAuthenticationProvider.generateDigest("alice:" + SECRET))); + Slf4jAuditLogger logger = new Slf4jAuditLogger(); + AtomicInteger emitted = new AtomicInteger(); + Object previous = AuditHelperTest.replaceProviderField("auditLogger", (AuditLogger) event -> { + logger.logAuditEvent(event); + if (emitted.incrementAndGet() == 1) { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "false"); + } + }); + try { + server.finishSessionInit(connection, true); + List logs = read(2); + assertBindings(logs, new String[][]{{"ip", "127.0.0.1"}, {"digest", "alice"}}); + for (String log : logs) { + assertBindingEvent(log, "sessionEstablished", "0x123", "success", null, "unknown", null); + } + server.finishSessionInit(connection, true); + read(0); + } finally { + AuditHelperTest.replaceProviderField("auditLogger", previous); + } + } + + @Test + public void testSinkAndCounterFailuresDoNotDiscardRemainingBindings() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("ip", "127.0.0.1")); + connection.addAuthInfo(new Id("digest", DigestAuthenticationProvider.generateDigest("alice:" + SECRET))); + FailingCounter counter = new FailingCounter(); + Counter previousCounter = AuditHelperTest.replaceAuditErrorCounter(counter); + Slf4jAuditLogger logger = new Slf4jAuditLogger(); + Object previousLogger = AuditHelperTest.replaceProviderField("auditLogger", (AuditLogger) event -> { + logger.logAuditEvent(event); + throw new IllegalStateException("synthetic per-binding sink failure"); + }); + try { + server.finishSessionInit(connection, true); + assertNull(connection.disconnectReason); + assertBindings(read(2), new String[][]{{"ip", "127.0.0.1"}, {"digest", "alice"}}); + assertEquals(2, counter.get()); + } finally { + AuditHelperTest.replaceProviderField("auditLogger", previousLogger); + AuditHelperTest.replaceAuditErrorCounter(previousCounter); + } + } + + @Test + public void testDisabledGatesDoNotReadBindingMetadataOrChangeAuthentication() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.authInfoRead = () -> { + throw new IllegalStateException("binding metadata must not be read"); + }; + property(AuditHelperTest.ENHANCED_ENABLE, null); + server.finishSessionInit(connection, true); + authenticate(connection, "digest", "alice:" + SECRET); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + read(0); + + property(AuditHelperTest.ENHANCED_ENABLE, "true"); + Object previous = AuditHelperTest.replaceProviderField("auditEnabled", false); + try { + server.finishSessionInit(connection, true); + authenticate(connection, "digest", "alice:" + SECRET); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + read(0); + } finally { + AuditHelperTest.replaceProviderField("auditEnabled", previous); + } + } + + @Test + public void testBindingKeepsCapturedModeWhenIdentityExtractionChangesTheGate() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.addAuthInfo(new Id("audit-test-custom", "alice:" + SECRET)); + connection.authInfoRead = () -> System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "false"); + server.finishSessionInit(connection, true); + String log = read(1).get(0); + assertBindingEvent(log, "sessionEstablished", "0x123", "success", null, "unknown", null); + assertBindings(log, new String[][]{{"audit-test-custom", "[redacted]"}}); + server.finishSessionInit(connection, true); + read(0); + } + + @Test + public void testAuditGateReadFailureDoesNotAbortAcceptedAuthentication() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + long errors = AuditHelperTest.auditErrors(); + Properties previous = failEnhancedGateRead(); + try { + try { + authenticate(connection, "digest", "alice:" + SECRET); + } catch (RuntimeException e) { + fail("Audit gate access must not abort accepted authentication: " + e); + } + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + assertEquals(errors + 1, AuditHelperTest.auditErrors()); + read(0); + } finally { + System.setProperties(previous); + } + } + + @Test + public void testAuditGateReadFailureDoesNotCloseAValidSession() { + RecordingCnxn connection = new RecordingCnxn(SESSION); + long errors = AuditHelperTest.auditErrors(); + Properties previous = failEnhancedGateRead(); + try { + server.finishSessionInit(connection, true); + assertNull("Audit gate access must not close a valid session", connection.disconnectReason); + assertEquals(errors + 1, AuditHelperTest.auditErrors()); + read(0); + } finally { + System.setProperties(previous); + } + } + + @Test + public void testMetadataFailureAndBothBrokenReportersCannotChangeAuthOrSession() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.authInfoRead = () -> { + throw new IllegalStateException("synthetic binding metadata failure"); + }; + FailingCounter counter = new FailingCounter(); + Counter previousCounter = AuditHelperTest.replaceAuditErrorCounter(counter); + Logger logger = (Logger) LoggerFactory.getLogger(AuditHelper.class); + Level previousLevel = logger.getLevel(); + AtomicInteger reports = new AtomicInteger(); + AppenderBase failingAppender = new AppenderBase() { + @Override + public void doAppend(ILoggingEvent event) { + reports.incrementAndGet(); + throw new IllegalStateException("synthetic diagnostic failure"); + } + + @Override + protected void append(ILoggingEvent event) { + } + }; + logger.setLevel(Level.ERROR); + logger.addAppender(failingAppender); + try { + server.finishSessionInit(connection, true); + authenticate(connection, "digest", "alice:" + SECRET); + assertNull(connection.disconnectReason); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + assertEquals(2, counter.get()); + assertEquals(2, reports.get()); + read(0); + connection.authInfoRead = null; + assertTrue(connection.getAuthInfo().contains( + new Id("digest", DigestAuthenticationProvider.generateDigest("alice:" + SECRET)))); + } finally { + logger.detachAppender(failingAppender); + logger.setLevel(previousLevel); + AuditHelperTest.replaceAuditErrorCounter(previousCounter); + } + } + + @Test + public void testAuthenticationKeepsCapturedModeDuringBindingExtraction() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + connection.authInfoRead = () -> System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "false"); + authenticate(connection, "audit-test-custom", "alice:" + SECRET); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", "0x123", "success", "0", "unknown", "-4"); + assertBindings(log, new String[][]{{"audit-test-custom", "[redacted]"}}); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + } + + @Test + public void testSaslCompletionDoesNotAuditIntermediateChallenges() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + try (SaslExchange sasl = new SaslExchange(connection, SECRET)) { + byte[] response = sasl.challenge(); + assertFalse(sasl.server.isComplete()); + read(0); + sasl.respond(response); + assertTrue(sasl.server.isComplete()); + assertTrue(sasl.client.isComplete()); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", "0x123", "success", "0", "unknown", "-33"); + assertBindings(log, new String[][]{{"sasl", "alice"}}); + assertTrue(connection.getAuthInfo().contains(new Id("sasl", "alice"))); + } + } + + @Test + public void testSaslFailurePreservesDefaultClosingPolicy() throws Exception { + assertSaslFailure(SESSION, false, false, Code.AUTHFAILED, true); + } + + @Test + public void testAllowedSaslFailureIsNotMistakenForSuccessfulAuthentication() throws Exception { + assertSaslFailure(SESSION, true, false, Code.OK, false); + } + + @Test + public void testRequiredSaslFailurePreservesItsDistinctClosingCode() throws Exception { + assertSaslFailure(SESSION, true, true, Code.SESSIONCLOSEDREQUIRESASLAUTH, true); + } + + @Test + public void testPreSessionSaslFailureOmitsSessionIdentity() throws Exception { + assertSaslFailure(0, false, false, Code.AUTHFAILED, true); + } + + @Test + public void testMissingSaslServerDoesNotFabricateAuthenticationSuccess() throws Exception { + RecordingCnxn connection = new RecordingCnxn(SESSION); + server.processPacket(connection, packet(OpCode.sasl, SASL_XID, new GetSASLRequest(new byte[0]))); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + read(0); + } + + @Test + public void testFailingAuditSinkAndCounterPreserveBothSaslFailurePolicies() throws Exception { + FailingCounter counter = new FailingCounter(); + Counter previousCounter = AuditHelperTest.replaceAuditErrorCounter(counter); + Slf4jAuditLogger logger = new Slf4jAuditLogger(); + Object previousLogger = AuditHelperTest.replaceProviderField("auditLogger", (AuditLogger) event -> { + logger.logAuditEvent(event); + throw new IllegalStateException("synthetic C2 SASL audit sink failure"); + }); + try { + assertSaslFailure(SESSION, false, false, Code.AUTHFAILED, true); + assertSaslFailure(SESSION, true, false, Code.OK, false); + assertEquals(2, counter.get()); + } finally { + AuditHelperTest.replaceProviderField("auditLogger", previousLogger); + AuditHelperTest.replaceAuditErrorCounter(previousCounter); + } + } + + @Test + public void testRealStandaloneAuthChangesDoNotRewriteEarlierWriteUsers() throws Exception { + Standalone fixture = new Standalone(); + fixture.setUp(); + try { + ZooKeeper client = fixture.connect(); + client.exists("/", false); + String session = "0x" + Long.toHexString(client.getSessionId()); + String attachment = read(1).get(0); + assertBindingEvent(attachment, "sessionEstablished", session, "success", null, "unknown", null); + assertBindings(attachment, new String[][]{{"ip", "127.0.0.1"}}); + + client.create("/before-auth", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + String before = read(1).get(0); + assertEquals("127.0.0.1", fields(before).get("user")); + client.addAuthInfo("digest", ("alice:" + SECRET).getBytes(StandardCharsets.UTF_8)); + client.exists("/", false); + assertBindings(read(1).get(0), new String[][]{{"digest", "alice"}}); + client.setData("/before-auth", new byte[]{1}, -1); + assertUsers(read(1).get(0), "127.0.0.1", "alice"); + + client.addAuthInfo("digest", ("bob:" + SECRET).getBytes(StandardCharsets.UTF_8)); + client.exists("/", false); + assertBindings(read(2), new String[][]{{"digest", "alice"}, {"digest", "bob"}}); + client.setData("/before-auth", new byte[]{2}, -1); + assertUsers(read(1).get(0), "127.0.0.1", "alice", "bob"); + assertEquals("127.0.0.1", fields(before).get("user")); + assertArrayEquals(new byte[]{2}, client.getData("/before-auth", false, null)); + } finally { + fixture.tearDown(); + } + } + + @Test + public void testRealAuthFailurePreservesTheClientError() throws Exception { + Standalone fixture = new Standalone(); + fixture.setUp(); + try { + ZooKeeper client = fixture.connect(); + client.exists("/", false); + String session = "0x" + Long.toHexString(client.getSessionId()); + read(1); + client.addAuthInfo("missing", TOKEN.getBytes(StandardCharsets.UTF_8)); + try { + client.exists("/", false); + fail("Unknown authentication provider must still fail the client"); + } catch (KeeperException.AuthFailedException expected) { + assertEquals(Code.AUTHFAILED, expected.code()); + } + String log = read(1).get(0); + assertBindingEvent(log, "authentication", session, "failure", "-115", "failed", "-4"); + assertBindings(log, new String[][]{{"missing", null}}); + } finally { + fixture.tearDown(); + } + } + + @Test + public void testRealQuorumMovementRebindsTheSameSessionWithoutOldConnectionAuth() throws Exception { + QuorumUtil quorum = new QuorumUtil(1); + TestableZooKeeper original = null; + ZooKeeper reconnected = null; + try { + quorum.startAll(); + capture.clear(); + ClientBase.CountdownWatcher watcher = new ClientBase.CountdownWatcher(); + original = new TestableZooKeeper( + quorum.getConnectString(quorum.getLeaderQuorumPeer()), ClientBase.CONNECTION_TIMEOUT, watcher); + watcher.waitForConnected(ClientBase.CONNECTION_TIMEOUT); + original.exists("/", false); + long session = original.getSessionId(); + byte[] passwd = original.getSessionPasswd(); + String sessionText = "0x" + Long.toHexString(session); + assertBindingEvent(read(1).get(0), "sessionEstablished", sessionText, "success", null, "unknown", null); + original.addAuthInfo("digest", ("alice:" + SECRET).getBytes(StandardCharsets.UTF_8)); + original.create("/moving", new byte[]{1}, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL); + List before = read(2); + assertBindings(before.get(0), new String[][]{{"digest", "alice"}}); + original.disconnect(); + + watcher = new ClientBase.CountdownWatcher(); + reconnected = new ZooKeeper( + quorum.getConnectString(quorum.getFollowerQuorumPeers().get(0)), + ClientBase.CONNECTION_TIMEOUT, watcher, session, passwd); + watcher.waitForConnected(ClientBase.CONNECTION_TIMEOUT); + assertEquals(session, reconnected.getSessionId()); + assertArrayEquals(new byte[]{1}, reconnected.getData("/moving", false, null)); + String attachment = read(1).get(0); + assertBindingEvent(attachment, "sessionEstablished", sessionText, "success", null, "unknown", null); + assertBindings(attachment, new String[][]{{"ip", "127.0.0.1"}}); + reconnected.addAuthInfo("digest", ("bob:" + SECRET).getBytes(StandardCharsets.UTF_8)); + reconnected.setData("/moving", new byte[]{2}, -1); + List after = read(2); + assertBindings(after.get(0), new String[][]{{"digest", "bob"}}); + assertUsers(after.get(1), "127.0.0.1", "bob"); + assertArrayEquals(new byte[]{2}, reconnected.getData("/moving", false, null)); + read(0); + } finally { + if (reconnected != null) { + reconnected.close(); + } + if (original != null) { + original.close(); + } + quorum.tearDown(); + } + } + + @Test + public void testFailingAuditSinkAndCounterDoNotRejectRealSessionAuthOrWrite() throws Exception { + FailingCounter counter = new FailingCounter(); + Counter previousCounter = AuditHelperTest.replaceAuditErrorCounter(counter); + Slf4jAuditLogger logger = new Slf4jAuditLogger(); + Object previousLogger = AuditHelperTest.replaceProviderField("auditLogger", (AuditLogger) event -> { + logger.logAuditEvent(event); + throw new IllegalStateException("synthetic C2 audit sink failure"); + }); + Standalone fixture = new Standalone(); + try { + fixture.setUp(); + ZooKeeper client = fixture.connect(); + client.addAuthInfo("digest", ("alice:" + SECRET).getBytes(StandardCharsets.UTF_8)); + client.create("/audit-failure", new byte[]{9}, ZooDefs.Ids.CREATOR_ALL_ACL, CreateMode.PERSISTENT); + assertArrayEquals(new byte[]{9}, client.getData("/audit-failure", false, null)); + List logs = read(3); + assertEquals("sessionEstablished", fields(logs.get(0)).get("operation")); + assertBindings(logs.get(1), new String[][]{{"digest", "alice"}}); + assertEquals("committed", fields(logs.get(2)).get("outcome")); + assertEquals(3, counter.get()); + } finally { + AuditHelperTest.replaceProviderField("auditLogger", previousLogger); + AuditHelperTest.replaceAuditErrorCounter(previousCounter); + fixture.tearDown(); + } + } + + private void assertSaslFailure(long session, boolean allow, boolean require, Code replyCode, boolean closed) + throws Exception { + property(ZooKeeperServer.ALLOW_SASL_FAILED_CLIENTS, Boolean.toString(allow)); + property(ZooKeeperServer.SESSION_REQUIRE_CLIENT_SASL_AUTH, Boolean.toString(require)); + RecordingCnxn connection = new RecordingCnxn(session); + try (SaslExchange sasl = new SaslExchange(connection, TOKEN)) { + byte[] response = sasl.challenge(); + read(0); + sasl.respond(response); + assertFalse(sasl.server.isComplete()); + assertEquals(replyCode.intValue(), connection.lastReply().getErr()); + assertEquals(2, connection.replies.size()); + assertEquals(closed, connection.sessionClosed); + String log = read(1).get(0); + assertBindingEvent(log, "authentication", session == 0 ? null : "0x123", + "failure", "-115", "failed", "-33"); + assertBindings(log, new String[][]{{"sasl", null}}); + } + } + + private void authenticate(RecordingCnxn connection, String scheme, String credentials) throws IOException { + server.processPacket(connection, packet(OpCode.auth, AUTH_XID, + new AuthPacket(0, scheme, credentials.getBytes(StandardCharsets.UTF_8)))); + } + + private static ByteBuffer packet(int type, int xid, Record record) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + BinaryOutputArchive archive = BinaryOutputArchive.getArchive(bytes); + new RequestHeader(xid, type).serialize(archive, "header"); + record.serialize(archive, "request"); + return ByteBuffer.wrap(bytes.toByteArray()); + } + + private List read(int count) { + List logs = capture.read(count); + captured.addAll(logs); + return logs; + } + + private void property(String name, String value) { + if (!previousProperties.containsKey(name)) { + previousProperties.put(name, System.getProperty(name)); + } + AuditHelperTest.restoreProperty(name, value); + } + + private static Properties failEnhancedGateRead() { + Properties previous = System.getProperties(); + Properties failing = new Properties() { + @Override + public String getProperty(String key) { + if (AuditHelperTest.ENHANCED_ENABLE.equals(key)) { + throw new SecurityException("synthetic audit gate access failure"); + } + return super.getProperty(key); + } + }; + failing.putAll(previous); + System.setProperties(failing); + return previous; + } + + private static void assertBindingEvent(String log, String operation, String session, String result, + String error, String outcome, String cxid) { + Map event = fields(log); + assertEquals("2", event.get("schema_version")); + assertEquals(operation, event.get("operation")); + assertEquals(session, event.get("session")); + assertEquals(result, event.get("result")); + assertEquals(error, event.get("error_code")); + assertEquals(outcome, event.get("outcome")); + assertEquals(cxid, event.get("cxid")); + assertEquals("127.0.0.1", event.get("ip")); + assertNull(event.get("zxid")); + assertNull(event.get("data_length")); + assertNull(event.get("znode")); + } + + private static void assertBindings(String log, String[][] expected) { + assertBindings(Collections.singletonList(log), expected); + } + + private static void assertBindings(List logs, String[][] expected) { + assertEquals(logs.toString(), expected.length, logs.size()); + Set> actual = new HashSet<>(); + for (String log : logs) { + Map binding = fields(log); + assertEquals("2", binding.get("schema_version")); + actual.add(Arrays.asList(unescape(binding.get("auth_scheme")), unescape(binding.get("user")))); + } + assertEquals("Duplicate scheme/principal binding", logs.size(), actual.size()); + Set> wanted = new HashSet<>(); + for (String[] binding : expected) { + wanted.add(Arrays.asList(binding)); + } + assertEquals(wanted, actual); + } + + private static String unescape(String encoded) { + if (encoded == null) { + return null; + } + StringBuilder decoded = new StringBuilder(); + for (int i = 0; i < encoded.length(); i++) { + char c = encoded.charAt(i); + if (c == '\\') { + char escaped = encoded.charAt(++i); + switch (escaped) { + case '\\': c = '\\'; break; + case 't': c = '\t'; break; + case 'r': c = '\r'; break; + case 'n': c = '\n'; break; + default: fail("Unexpected v2 escape: " + escaped); + } + } + decoded.append(c); + } + return decoded.toString(); + } + + private static void assertUsers(String log, String... users) { + assertEquals(new HashSet<>(Arrays.asList(users)), + new HashSet<>(Arrays.asList(fields(log).get("user").split(",")))); + } + + public static class EmptyAuthenticationProvider extends CredentialAuthenticationProvider { + @Override + public String getScheme() { + return "audit-c2-empty"; + } + + @Override + public Code handleAuthentication(ServerCnxn connection, byte[] data) { + return Code.OK; + } + } + + private static final class Standalone extends ClientBase { + private TestableZooKeeper connect() throws IOException, InterruptedException { + return createClient(); + } + } + + private static final class RecordingCnxn extends MockServerCnxn { + private final List replies = new ArrayList<>(); + private long sessionId; + private boolean sessionClosed; + private DisconnectReason disconnectReason; + private SetSASLResponse saslResponse; + private Runnable authInfoRead; + + private RecordingCnxn(long sessionId) { + this.sessionId = sessionId; + } + + @Override + public long getSessionId() { + return sessionId; + } + + @Override + public InetSocketAddress getRemoteSocketAddress() { + return new InetSocketAddress("127.0.0.1", 2181); + } + + @Override + public List getAuthInfo() { + if (authInfoRead != null) { + authInfoRead.run(); + } + return super.getAuthInfo(); + } + + @Override + public void sendResponse(ReplyHeader header, Record record, String tag, + String cacheKey, Stat stat, int opCode) { + replies.add(header); + if (record instanceof SetSASLResponse) { + saslResponse = (SetSASLResponse) record; + } + } + + @Override + public void sendCloseSession() { + sessionClosed = true; + } + + @Override + public void close(DisconnectReason reason) { + disconnectReason = reason; + } + + private ReplyHeader lastReply() { + assertFalse("Server must produce a protocol reply", replies.isEmpty()); + return replies.get(replies.size() - 1); + } + + private void saslServer(ZooKeeperSaslServer saslServer) { + zooKeeperSaslServer = saslServer; + } + } + + private final class SaslExchange implements AutoCloseable { + private final SaslServer server; + private final SaslClient client; + private final RecordingCnxn connection; + + private SaslExchange(RecordingCnxn connection, String clientPassword) throws Exception { + this.connection = connection; + Map properties = Collections.singletonMap(Sasl.QOP, "auth"); + server = Sasl.createSaslServer("DIGEST-MD5", "zookeeper", "localhost", + properties, callbacks(SECRET)); + client = Sasl.createSaslClient(new String[]{"DIGEST-MD5"}, null, "zookeeper", "localhost", + properties, callbacks(clientPassword)); + assertNotNull("JDK must provide DIGEST-MD5", server); + assertNotNull("JDK must provide DIGEST-MD5", client); + // The package-private wrapper constructor requires a JAAS Login; keep its methods and the JDK engine real. + ZooKeeperSaslServer wrapper = mock(ZooKeeperSaslServer.class, CALLS_REAL_METHODS); + Field engine = ZooKeeperSaslServer.class.getDeclaredField("saslServer"); + engine.setAccessible(true); + engine.set(wrapper, server); + connection.saslServer(wrapper); + } + + private byte[] challenge() throws IOException { + SessionAuthAuditTest.this.server.processPacket(connection, + packet(OpCode.sasl, SASL_XID, new GetSASLRequest(new byte[0]))); + assertEquals(Code.OK.intValue(), connection.lastReply().getErr()); + assertNotNull(connection.saslResponse.getToken()); + return client.evaluateChallenge(connection.saslResponse.getToken()); + } + + private void respond(byte[] response) throws IOException { + SessionAuthAuditTest.this.server.processPacket(connection, + packet(OpCode.sasl, SASL_XID, new GetSASLRequest(response))); + if (server.isComplete()) { + client.evaluateChallenge(connection.saslResponse.getToken()); + } + } + + @Override + public void close() throws Exception { + client.dispose(); + server.dispose(); + } + } + + private static CallbackHandler callbacks(String password) { + return callbacks -> { + for (Callback callback : callbacks) { + if (callback instanceof NameCallback) { + ((NameCallback) callback).setName("alice"); + } else if (callback instanceof PasswordCallback) { + ((PasswordCallback) callback).setPassword(password.toCharArray()); + } else if (callback instanceof RealmCallback) { + RealmCallback realm = (RealmCallback) callback; + realm.setText(realm.getDefaultText()); + } else if (callback instanceof AuthorizeCallback) { + AuthorizeCallback authorization = (AuthorizeCallback) callback; + authorization.setAuthorized( + authorization.getAuthenticationID().equals(authorization.getAuthorizationID())); + } else { + throw new UnsupportedCallbackException(callback); + } + } + }; + } +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/Slf4JAuditLoggerTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/Slf4JAuditLoggerTest.java index f8d3c7b47af..7e04b448627 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/Slf4JAuditLoggerTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/Slf4JAuditLoggerTest.java @@ -19,14 +19,12 @@ import static org.apache.zookeeper.test.ClientBase.CONNECTION_TIMEOUT; import static org.junit.Assert.assertEquals; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.LineNumberReader; -import java.io.StringReader; import java.net.InetAddress; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.apache.zookeeper.CreateMode; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.KeeperException.Code; @@ -36,6 +34,7 @@ import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooKeeper; import org.apache.zookeeper.audit.AuditEvent.Result; +import org.apache.zookeeper.audit.AuditHelperTest.AuditCapture; import org.apache.zookeeper.data.ACL; import org.apache.zookeeper.data.Stat; import org.apache.zookeeper.server.Request; @@ -43,7 +42,6 @@ import org.apache.zookeeper.server.quorum.QuorumPeerTestBase; import org.apache.zookeeper.test.ClientBase; import org.apache.zookeeper.test.ClientBase.CountdownWatcher; -import org.apache.zookeeper.test.LoggerTestTool; import org.junit.AfterClass; import org.junit.Assert; import org.junit.Before; @@ -57,14 +55,14 @@ public class Slf4JAuditLoggerTest extends QuorumPeerTestBase { private static int SERVER_COUNT = 3; private static MainThread[] mt; private static ZooKeeper zk; - private static ByteArrayOutputStream os; + private static AuditCapture os; @BeforeClass public static void setUpBeforeClass() throws Exception { System.setProperty(ZKAuditProvider.AUDIT_ENABLE, "true"); + System.setProperty("zookeeper.extendedTypesEnabled", "true"); // setup the logger to capture all logs - LoggerTestTool loggerTestTool = new LoggerTestTool(Slf4jAuditLogger.class); - os = loggerTestTool.getOutputStream(); + os = new AuditCapture(); mt = startQuorum(); zk = ClientBase.createZKClient("127.0.0.1:" + mt[0].getQuorumPeer().getClientPort()); //Verify start audit log here itself @@ -75,7 +73,7 @@ public static void setUpBeforeClass() throws Exception { @Before public void setUp() { - os.reset(); + os.clear(); } @Test @@ -102,13 +100,47 @@ public void testCreateAuditLogs() null, createMode), readAuditLog(os)); } + @Test + public void testCreateWithTtlAuditLogs() throws Exception { + String path = zk.create("/createTtlPath", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_WITH_TTL, null, 60000); + verifyLog(getAuditLog(AuditConstants.OP_CREATE, path, Result.SUCCESS, + null, "persistent_with_ttl"), readAuditLog(os)); + } + + @Test + public void testCreateSequentialWithTtlAuditLogs() throws Exception { + String path = zk.create("/createTtlSeqPath", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_SEQUENTIAL_WITH_TTL, null, 60000); + verifyLog(getAuditLog(AuditConstants.OP_CREATE, path, Result.SUCCESS, + null, "persistent_sequential_with_ttl"), readAuditLog(os)); + } + + @Test + public void testEnhancedEscapingThroughSlf4j() { + String previous = System.getProperty(AuditHelperTest.ENHANCED_ENABLE); + try (AuditHelperTest.AuditCapture capture = new AuditHelperTest.AuditCapture()) { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZKAuditProvider.log("team\tname\r\n\\t", "create", "/name=value\\child", + null, "persistent", "0x123", "127.0.0.1", Result.SUCCESS); + String log = capture.read(1).get(0); + assertEquals("2", AuditHelperTest.fields(log).get("schema_version")); + assertEquals("team\\tname\\r\\n\\\\t", AuditHelperTest.fields(log).get("user")); + assertEquals("/name=value\\\\child", AuditHelperTest.fields(log).get("znode")); + Assert.assertFalse(log.contains("\n")); + Assert.assertFalse(log.contains("\r")); + } finally { + AuditHelperTest.restoreProperty(AuditHelperTest.ENHANCED_ENABLE, previous); + } + } + @Test public void testDeleteAuditLogs() throws InterruptedException, IOException, KeeperException { String path = "/deletePath"; zk.create(path, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - os.reset(); + os.clear(); try { zk.delete(path, -100); } catch (KeeperException exception) { @@ -129,7 +161,7 @@ public void testSetDataAuditLogs() String path = "/setDataPath"; zk.create(path, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - os.reset(); + os.clear(); try { zk.setData(path, "newData".getBytes(), -100); } catch (KeeperException exception) { @@ -151,7 +183,7 @@ public void testSetACLAuditLogs() String path = "/aclPath"; zk.create(path, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - os.reset(); + os.clear(); try { zk.setACL(path, openAclUnsafe, -100); } catch (KeeperException exception) { @@ -243,6 +275,35 @@ public void testEphemralZNodeAuditLogs() ZKAuditProvider.getZKUser(), null), readAuditLog(os, SERVER_COUNT)); } + @Test + public void testEnhancedSystemDeletionIdentityAcrossReplicas() throws Exception { + String previous = System.getProperty(AuditHelperTest.ENHANCED_ENABLE); + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + try (ZooKeeper client = ClientBase.createZKClient("127.0.0.1:" + mt[0].getQuorumPeer().getClientPort())) { + Map binding = AuditHelperTest.fields(os.await(1, CONNECTION_TIMEOUT).get(0)); + assertEquals(AuditConstants.OP_SESSION_ESTABLISHED, binding.get("operation")); + assertEquals("ip", binding.get("auth_scheme")); + client.create("/enhanced-ephemeral", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL); + String session = "0x" + Long.toHexString(client.getSessionId()); + os.read(1); + client.close(); + List logs = os.await(SERVER_COUNT, CONNECTION_TIMEOUT); + String zxid = Long.toString(zk.exists("/", false).getPzxid()); + for (String log : logs) { + Map fields = AuditHelperTest.fields(log); + AuditHelperTest.assertWrite(fields, AuditConstants.OP_DEL_EZNODE_EXP, + "/enhanced-ephemeral", null, "committed", "0"); + assertEquals(zxid, fields.get("zxid")); + assertEquals(session, fields.get("session")); + assertEquals(ZKAuditProvider.getZKUser(), fields.get("user")); + Assert.assertNull(fields.get("cxid")); + Assert.assertNull(fields.get("ip")); + } + } finally { + AuditHelperTest.restoreProperty(AuditHelperTest.ENHANCED_ENABLE, previous); + } + } + private static String getStartLog() { // user=userName operation=ZooKeeperServer start result=success @@ -309,10 +370,7 @@ private ServerCnxn getServerCnxn() { } private static void verifyLog(String expectedLog, String log) { - String searchString = " - "; - int logStartIndex = log.indexOf(searchString); - String auditLog = log.substring(logStartIndex + searchString.length()); - Assert.assertTrue(auditLog.endsWith(expectedLog)); + Assert.assertTrue(log, log.endsWith(expectedLog)); } private static void verifyLogs(String expectedLog, List logs) { @@ -321,37 +379,14 @@ private static void verifyLogs(String expectedLog, List logs) { } } - private String readAuditLog(ByteArrayOutputStream os) throws IOException { + private String readAuditLog(AuditCapture os) throws IOException { return readAuditLog(os, 1).get(0); } - private static List readAuditLog(ByteArrayOutputStream os, + private static List readAuditLog(AuditCapture os, int numberOfLogEntry) throws IOException { - return readAuditLog(os, numberOfLogEntry, false); - } - - private static List readAuditLog(ByteArrayOutputStream os, - int numberOfLogEntry, - boolean skipEphemralDeletion) throws IOException { - List logs = new ArrayList<>(); - LineNumberReader r = new LineNumberReader( - new StringReader(os.toString())); - String line; - while ((line = r.readLine()) != null) { - if (skipEphemralDeletion - && line.contains(AuditConstants.OP_DEL_EZNODE_EXP)) { - continue; - } - logs.add(line); - } - os.reset(); - assertEquals( - "Expected number of log entries are not generated. Logs are " - + logs, - numberOfLogEntry, logs.size()); - return logs; - + return os.read(numberOfLogEntry); } private static MainThread[] startQuorum() throws IOException { @@ -411,6 +446,7 @@ private void waitForDeletion(ZooKeeper zooKeeper, String path) @AfterClass public static void tearDownAfterClass() { System.clearProperty(ZKAuditProvider.AUDIT_ENABLE); + System.clearProperty("zookeeper.extendedTypesEnabled"); for (int i = 0; i < SERVER_COUNT; i++) { try { if (mt[i] != null) { @@ -419,11 +455,7 @@ public static void tearDownAfterClass() { } catch (InterruptedException e) { e.printStackTrace(); } - try { - os.close(); - } catch (IOException e) { - e.printStackTrace(); - } } + os.close(); } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/StandaloneServerAuditTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/StandaloneServerAuditTest.java index cf8ca8eea36..344d6e07da3 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/audit/StandaloneServerAuditTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/audit/StandaloneServerAuditTest.java @@ -19,34 +19,67 @@ package org.apache.zookeeper.audit; +import static org.apache.zookeeper.audit.AuditHelperTest.assertWrite; +import static org.apache.zookeeper.audit.AuditHelperTest.fields; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import java.io.ByteArrayOutputStream; +import static org.junit.Assert.fail; import java.io.IOException; -import java.io.LineNumberReader; -import java.io.StringReader; -import java.util.ArrayList; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Map; import org.apache.zookeeper.CreateMode; import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.KeeperException.Code; +import org.apache.zookeeper.Op; +import org.apache.zookeeper.OpResult; import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooKeeper; +import org.apache.zookeeper.audit.AuditHelperTest.AuditCapture; +import org.apache.zookeeper.audit.AuditHelperTest.CredentialAuthenticationProvider; +import org.apache.zookeeper.audit.AuditHelperTest.FailingCounter; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.data.Stat; +import org.apache.zookeeper.metrics.Counter; +import org.apache.zookeeper.server.auth.DigestAuthenticationProvider; +import org.apache.zookeeper.server.auth.ProviderRegistry; import org.apache.zookeeper.test.ClientBase; -import org.apache.zookeeper.test.LoggerTestTool; +import org.junit.After; import org.junit.AfterClass; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; public class StandaloneServerAuditTest extends ClientBase { - private static ByteArrayOutputStream os; + private AuditCapture capture; + private String previousEnhanced; + private String previousExtendedTypes; @BeforeClass public static void setup() { System.setProperty(ZKAuditProvider.AUDIT_ENABLE, "true"); - LoggerTestTool loggerTestTool = new LoggerTestTool(Slf4jAuditLogger.class); - os = loggerTestTool.getOutputStream(); + } + + @Before + public void captureAuditLogs() { + previousEnhanced = System.getProperty(AuditHelperTest.ENHANCED_ENABLE); + previousExtendedTypes = System.getProperty("zookeeper.extendedTypesEnabled"); + capture = new AuditCapture(); + } + + @After + public void restoreAuditSettings() { + capture.close(); + AuditHelperTest.restoreProperty(AuditHelperTest.ENHANCED_ENABLE, previousEnhanced); + AuditHelperTest.restoreProperty("zookeeper.extendedTypesEnabled", previousExtendedTypes); } @AfterClass @@ -60,21 +93,277 @@ public void testCreateAuditLog() throws KeeperException, InterruptedException, I String path = "/createPath"; zk.create(path, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - List logs = readAuditLog(os); + List logs = capture.read(1); assertEquals(1, logs.size()); assertTrue(logs.get(0).endsWith("operation=create\tznode=/createPath\tznode_type=persistent\tresult=success")); } - private static List readAuditLog(ByteArrayOutputStream os) throws IOException { - List logs = new ArrayList<>(); - LineNumberReader r = new LineNumberReader( - new StringReader(os.toString())); - String line; - while ((line = r.readLine()) != null) { - logs.add(line); + @Test + public void testEnhancedWriteResultsMatchClientResponses() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + byte[] data = "audit-secret".getBytes(StandardCharsets.UTF_8); + zk.create("/enhanced", data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + String log = capture.read(1).get(0); + Map created = fields(log); + assertWrite(created, "create", "/enhanced", "12", "committed", "0"); + assertFalse(log.contains("audit-secret")); + assertEquals(Long.toString(zk.exists("/enhanced", false).getCzxid()), created.get("zxid")); + + try { + zk.create("/enhanced", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + fail("Duplicate create must fail"); + } catch (KeeperException e) { + assertEquals(Code.NODEEXISTS, e.code()); } - os.reset(); - return logs; + assertWrite(fields(capture.read(1).get(0)), "create", "/enhanced", "7", "failed", "-110"); + + byte[] unicode = "\u00e9\ud83d\ude00".getBytes(StandardCharsets.UTF_8); + Stat changed = zk.setData("/enhanced", unicode, -1); + Map changedLog = fields(capture.read(1).get(0)); + assertWrite(changedLog, "setData", "/enhanced", "6", "committed", "0"); + assertEquals(Long.toString(changed.getMzxid()), changedLog.get("zxid")); + try { + zk.setData("/enhanced", new byte[1], -100); + fail("Invalid version must fail"); + } catch (KeeperException e) { + assertEquals(Code.BADVERSION, e.code()); + } + assertWrite(fields(capture.read(1).get(0)), "setData", "/enhanced", "1", "failed", "-103"); + assertArrayEquals(unicode, zk.getData("/enhanced", false, null)); + zk.getChildren("/", false); + zk.exists("/enhanced", false); + capture.read(0); } -} + @Test + public void testEnhancedCreateVariants() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + System.setProperty("zookeeper.extendedTypesEnabled", "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + Stat stat = new Stat(); + zk.create("/create2", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT, stat); + assertWrite(fields(capture.read(1).get(0)), "create", "/create2", "0", "committed", "0"); + zk.create("/container", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.CONTAINER); + Map container = fields(capture.read(1).get(0)); + assertWrite(container, "create", "/container", "2", "committed", "0"); + assertEquals("container", container.get("znode_type")); + String path = zk.create("/ttl-", new byte[3], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_SEQUENTIAL_WITH_TTL, null, 60000); + Map ttl = fields(capture.read(1).get(0)); + assertWrite(ttl, "create", path, "3", "committed", "0"); + assertEquals("persistent_sequential_with_ttl", ttl.get("znode_type")); + + zk.create("/ttl", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_WITH_TTL, null, 60000); + assertWrite(fields(capture.read(1).get(0)), "create", "/ttl", "0", "committed", "0"); + try { + zk.create("/ttl", new byte[4], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_WITH_TTL, null, 60000); + fail("Duplicate TTL create must fail"); + } catch (KeeperException e) { + assertEquals(Code.NODEEXISTS, e.code()); + } + Map failedTtl = fields(capture.read(1).get(0)); + assertWrite(failedTtl, "create", "/ttl", "4", "failed", "-110"); + assertEquals("persistent_with_ttl", failedTtl.get("znode_type")); + } + + @Test + public void testEnhancedMultiUsesReturnedPathsAndCompleteIndexes() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + System.setProperty("zookeeper.extendedTypesEnabled", "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + List results = zk.multi(Arrays.asList( + Op.create("/same", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.delete("/same", -1), + Op.check("/", -1), + Op.create("/same", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL), + Op.create("/seq-", new byte[3], ZooDefs.Ids.OPEN_ACL_UNSAFE, + CreateMode.PERSISTENT_SEQUENTIAL_WITH_TTL, 60000))); + assertEquals(5, results.size()); + String finalPath = ((OpResult.CreateResult) results.get(4)).getPath(); + assertFalse("/seq-".equals(finalPath)); + List logs = capture.read(4); + assertWrite(fields(logs.get(0)), "create", "/same", "1", "committed", "0"); + assertEquals("persistent", fields(logs.get(0)).get("znode_type")); + assertWrite(fields(logs.get(1)), "delete", "/same", null, "committed", "0"); + assertWrite(fields(logs.get(2)), "create", "/same", "2", "committed", "0"); + assertEquals("ephemeral", fields(logs.get(2)).get("znode_type")); + assertEquals("3", fields(logs.get(2)).get("multi_index")); + assertWrite(fields(logs.get(3)), "create", finalPath, "3", "committed", "0"); + assertEquals("4", fields(logs.get(3)).get("multi_index")); + assertEquals("persistent_sequential_with_ttl", fields(logs.get(3)).get("znode_type")); + assertArrayEquals(new byte[3], zk.getData(finalPath, false, null)); + } + + @Test + public void testEnhancedFailedMultiMatchesAtomicRollback() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + try { + zk.multi(Arrays.asList( + Op.create("/rolled", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.check("/", -1), + Op.setData("/missing", new byte[3], -1), + Op.create("/later", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT))); + fail("Missing node must abort the multi"); + } catch (KeeperException e) { + assertEquals(Code.NONODE, e.code()); + } + assertNull(zk.exists("/rolled", false)); + assertNull(zk.exists("/later", false)); + List logs = capture.read(4); + assertWrite(fields(logs.get(0)), "multiOperation", null, null, "failed", "-101"); + assertWrite(fields(logs.get(1)), "create", "/rolled", "1", "rolled_back", "0"); + assertWrite(fields(logs.get(2)), "setData", "/missing", "3", "failed", "-101"); + assertWrite(fields(logs.get(3)), "create", "/later", "2", "rolled_back", "-2"); + assertEquals("0", fields(logs.get(1)).get("multi_index")); + assertEquals("2", fields(logs.get(2)).get("multi_index")); + assertEquals("3", fields(logs.get(3)).get("multi_index")); + } + + @Test + public void testFailedCheckRollsBackMutationsWithoutACheckEvent() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + try { + zk.multi(Arrays.asList( + Op.create("/rolled", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT), + Op.check("/absent", -1), + Op.setData("/rolled", new byte[2], -1))); + fail("Missing check target must abort the multi"); + } catch (KeeperException e) { + assertEquals(Code.NONODE, e.code()); + } + assertNull(zk.exists("/rolled", false)); + List logs = capture.read(3); + assertWrite(fields(logs.get(0)), "multiOperation", null, null, "failed", "-101"); + assertWrite(fields(logs.get(1)), "create", "/rolled", "1", "rolled_back", "0"); + assertWrite(fields(logs.get(2)), "setData", "/rolled", "2", "rolled_back", "-2"); + assertEquals("2", fields(logs.get(2)).get("multi_index")); + } + + @Test + public void testEnhancedAclRedactionDoesNotChangeStoredAcl() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + String credentials = "alice:synthetic-password"; + String digest = DigestAuthenticationProvider.generateDigest(credentials); + zk.addAuthInfo("digest", credentials.getBytes(StandardCharsets.UTF_8)); + zk.create("/acl", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + List initialLogs = capture.read(2); + assertEquals(AuditConstants.OP_AUTHENTICATION, fields(initialLogs.get(0)).get("operation")); + assertEquals("digest", fields(initialLogs.get(0)).get("auth_scheme")); + assertEquals(AuditConstants.OP_CREATE, fields(initialLogs.get(1)).get("operation")); + List acls = Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("digest", digest))); + zk.setACL("/acl", acls, -1); + String log = capture.read(1).get(0); + assertWrite(fields(log), "setAcl", "/acl", null, "committed", "0"); + assertEquals("digest:alice:cdrwa", fields(log).get("acl")); + assertFalse(log.contains(digest)); + assertFalse(log.contains("synthetic-password")); + assertEquals(acls, zk.getACL("/acl", new Stat())); + capture.read(0); + } + + @Test + public void testRegisteredCustomUserIsRedactedOnlyInEnhancedMode() throws Exception { + String property = ProviderRegistry.AUTHPROVIDER_PROPERTY_PREFIX + "c1-audit-user"; + String previous = System.getProperty(property); + System.setProperty(property, CredentialAuthenticationProvider.class.getName()); + ProviderRegistry.initialize(); + try { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + zk.addAuthInfo("audit-test-custom", "alice:synthetic-password".getBytes(StandardCharsets.UTF_8)); + zk.create("/custom-user", new byte[1], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + List initialLogs = capture.read(2); + assertEquals(AuditConstants.OP_AUTHENTICATION, fields(initialLogs.get(0)).get("operation")); + assertEquals("audit-test-custom", fields(initialLogs.get(0)).get("auth_scheme")); + String enhanced = initialLogs.get(1); + assertWrite(fields(enhanced), "create", "/custom-user", "1", "committed", "0"); + assertFalse(enhanced.contains("synthetic-password")); + List enhancedUsers = Arrays.asList(fields(enhanced).get("user").split(",")); + Collections.sort(enhancedUsers); + assertEquals(Arrays.asList("127.0.0.1", "[redacted]"), enhancedUsers); + + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "false"); + zk.setData("/custom-user", new byte[2], -1); + Map legacy = fields(capture.read(1).get(0)); + assertNull(legacy.get("schema_version")); + List legacyUsers = Arrays.asList(legacy.get("user").split(",")); + Collections.sort(legacyUsers); + assertEquals(Arrays.asList("127.0.0.1", "alice:synthetic-password"), legacyUsers); + assertArrayEquals(new byte[2], zk.getData("/custom-user", false, null)); + } finally { + ProviderRegistry.removeProvider("audit-test-custom"); + AuditHelperTest.restoreProperty(property, previous); + } + } + + @Test + public void testAuditFailureDoesNotRejectValidWrite() throws Exception { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, "true"); + ZooKeeper zk = createClient(); + assertSessionEstablished(); + AuditLogger failingLogger = event -> { + throw new IllegalStateException("synthetic audit sink failure"); + }; + Object previous = AuditHelperTest.replaceProviderField("auditLogger", failingLogger); + long before = AuditHelperTest.auditErrors(); + try { + assertEquals("/valid", zk.create("/valid", new byte[2], + ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT)); + assertArrayEquals(new byte[2], zk.getData("/valid", false, null)); + assertEquals(before + 1, AuditHelperTest.auditErrors()); + } finally { + AuditHelperTest.replaceProviderField("auditLogger", previous); + } + } + + @Test + public void testAuditReporterFailureDoesNotRejectAppliedWrite() throws Exception { + ZooKeeper zk = createClient(); + FailingCounter counter = new FailingCounter(); + Counter previousCounter = AuditHelperTest.replaceAuditErrorCounter(counter); + Object previousLogger = AuditHelperTest.replaceProviderField("auditLogger", (AuditLogger) event -> { + throw new IllegalStateException("synthetic audit sink failure"); + }); + try { + for (String enhanced : Arrays.asList("false", "true")) { + System.setProperty(AuditHelperTest.ENHANCED_ENABLE, enhanced); + String path = "/reporter-" + enhanced; + Code replyError = null; + String created = null; + long before = counter.get(); + try { + created = zk.create(path, new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } catch (KeeperException e) { + replyError = e.code(); + } + assertArrayEquals(new byte[2], zk.getData(path, false, null)); + assertNull("Audit reporting changed the reply after the write was applied", replyError); + assertEquals(path, created); + assertEquals("Do not retry a failing error reporter", before + 1, counter.get()); + } + } finally { + AuditHelperTest.replaceProviderField("auditLogger", previousLogger); + AuditHelperTest.replaceAuditErrorCounter(previousCounter); + } + } + + private void assertSessionEstablished() throws InterruptedException { + Map binding = fields(capture.await(1, CONNECTION_TIMEOUT).get(0)); + assertEquals(AuditConstants.OP_SESSION_ESTABLISHED, binding.get("operation")); + assertEquals("ip", binding.get("auth_scheme")); + assertEquals("success", binding.get("result")); + } +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java index 2aad3b79e8c..5892e81bd8d 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java @@ -18,6 +18,7 @@ package org.apache.zookeeper.server; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -32,10 +33,16 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -262,6 +269,295 @@ public void testPathTrieClearOnDeserialize() throws Exception { assertEquals("/bug is still in pTrie", "/", pTrie.findMaxPrefix("/bug")); } + @Test + public void testQuotaStatsRejectsMalformedUsage() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + String[] values = { + "other=1,bytes=3", "count=1,other=3", "bytes=3,count=1", "count=1", + "count=1,bytes=3,", "count=1,bytes=3,extra=4", "count=1=2,bytes=3", + "count=+1,bytes=3", "count=1,bytes=+3", "count=1,bytes=3\n", + " count=1,bytes=3", "count=1,bytes=3.0", "count=\u0661,bytes=3", "sensitive-token" + }; + for (String value : values) { + setQuotaData(tree, Quotas.statPath("/quota-test"), value); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_stats"); + } + tree.setData(Quotas.statPath("/quota-test"), new byte[]{(byte) 0xc3, (byte) 0x28}, 1, 2, 2); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_stats"); + } + + @Test + public void testQuotaStatsRejectsMalformedLimits() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + for (String value : Arrays.asList("other=10,bytes=100", "bytes=100,count=10", + "count=10,bytes=", "count=10,bytes=100,", + "count=10,bytes=100=sensitive-token", "sensitive-token")) { + setQuotaData(tree, Quotas.quotaPath("/quota-test"), value); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_limits"); + } + } + + @Test + public void testQuotaStatsRejectsNullOrEmptyMetadata() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + for (String value : Arrays.asList(null, "")) { + setQuotaData(tree, Quotas.statPath("/quota-test"), value); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_stats"); + } + setQuotaData(tree, Quotas.statPath("/quota-test"), "count=1,bytes=3"); + for (String value : Arrays.asList(null, "")) { + setQuotaData(tree, Quotas.quotaPath("/quota-test"), value); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_limits"); + } + } + + @Test + public void testQuotaStatsRejectsOutOfRangeUsage() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + for (String value : Arrays.asList("count=-1,bytes=3", "count=1,bytes=-1", + "count=-2,bytes=3", "count=2147483648,bytes=3", + "count=1,bytes=9223372036854775808")) { + setQuotaData(tree, Quotas.statPath("/quota-test"), value); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_stats"); + } + } + + @Test + public void testQuotaStatsRejectsOutOfRangeLimits() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + for (String value : Arrays.asList("count=-2,bytes=100", "count=10,bytes=-2", + "count=2147483648,bytes=100", + "count=10,bytes=9223372036854775808")) { + setQuotaData(tree, Quotas.quotaPath("/quota-test"), value); + assertQuotaUnavailable(sampleQuota(tree), "invalid_quota_limits"); + } + } + + @Test + public void testQuotaStatsIncompleteMetadata() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + tree.deleteNode(Quotas.statPath("/quota-test"), 2); + assertQuotaUnavailable(sampleQuota(tree), "quota_incomplete"); + tree.createNode(Quotas.statPath("/quota-test"), new byte[0], null, 0, -1, 3, 3); + tree.deleteNode(Quotas.quotaPath("/quota-test"), 4); + assertQuotaUnavailable(sampleQuota(tree), "quota_incomplete"); + tree.deleteNode(Quotas.statPath("/quota-test"), 5); + assertQuotaUnavailable(sampleQuota(tree), "quota_missing"); + } + + @Test + public void testQuotaStatsNamespaceRemoval() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + tree.deleteNode("/quota-test", 2); + assertNotNull(tree.getNode(Quotas.statPath("/quota-test"))); + assertNotNull(tree.getNode(Quotas.quotaPath("/quota-test"))); + assertQuotaUnavailable(sampleQuota(tree), "namespace_missing"); + tree.createNode("/quota-test", new byte[5], null, 0, -1, 3, 3); + DataTree.QuotaStats sample = sampleQuota(tree); + assertTrue(sample.isAvailable()); + assertEquals(Integer.valueOf(1), sample.getCountUsed()); + assertEquals(Long.valueOf(5), sample.getBytesUsed()); + } + + @Test + public void testQuotaStatsDetectsRemovalDuringSample() throws Exception { + for (String path : Arrays.asList("/quota-test", Quotas.statPath("/quota-test"), Quotas.quotaPath("/quota-test"))) { + ChangingQuotaTree tree = new ChangingQuotaTree(); + createQuotaTree(tree); + tree.changePath = path; + assertQuotaUnavailable(sampleQuota(tree), "quota_changed"); + } + } + + @Test + public void testQuotaStatsDetectsReplacementDuringSample() throws Exception { + for (String path : Arrays.asList("/quota-test", Quotas.statPath("/quota-test"), Quotas.quotaPath("/quota-test"))) { + ChangingQuotaTree tree = new ChangingQuotaTree(); + createQuotaTree(tree); + tree.changePath = path; + tree.replace = true; + assertQuotaUnavailable(sampleQuota(tree), "quota_changed"); + } + } + + @Test + public void testQuotaStatsDoesNotTraverseMissingQuota() throws Exception { + BoundedQuotaTree tree = new BoundedQuotaTree(); + tree.createNode("/quota-test", new byte[3], null, 0, -1, 1, 1); + for (int i = 0; i < 100; i++) { + tree.createNode("/quota-test/child" + i, new byte[4], null, 0, -1, 1, 1); + } + tree.sampling = true; + assertQuotaUnavailable(sampleQuota(tree), "quota_missing"); + assertTrue(tree.lookups <= 6); + } + + @Test + public void testQuotaStatsSamplesMetadataWithoutRecounting() throws Exception { + BoundedQuotaTree tree = new BoundedQuotaTree(); + createQuotaTree(tree); + setQuotaData(tree, Quotas.statPath("/quota-test"), "count=19,bytes=999"); + byte[] data = tree.getNode(Quotas.statPath("/quota-test")).getData().clone(); + long digest = tree.getTreeDigest(); + long size = tree.cachedApproximateDataSize(); + int watches = tree.getWatchCount(); + Map metrics = MetricsUtils.currentServerMetrics(); + tree.sampling = true; + DataTree.QuotaStats sample = sampleQuota(tree); + tree.sampling = false; + + assertTrue(sample.isAvailable()); + assertEquals(Integer.valueOf(19), sample.getCountUsed()); + assertEquals(Long.valueOf(999), sample.getBytesUsed()); + assertTrue(tree.lookups <= 6); + assertArrayEquals(data, tree.getNode(Quotas.statPath("/quota-test")).getData()); + assertEquals(digest, tree.getTreeDigest()); + assertEquals(size, tree.cachedApproximateDataSize()); + assertEquals(watches, tree.getWatchCount()); + assertEquals(metrics, MetricsUtils.currentServerMetrics()); + setQuotaData(tree, Quotas.statPath("/quota-test"), "count=2,bytes=7"); + assertEquals(Integer.valueOf(19), sample.getCountUsed()); + assertEquals(Long.valueOf(999), sample.getBytesUsed()); + } + + @Test + public void testQuotaStatsNumericBoundaries() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + setQuotaData(tree, Quotas.statPath("/quota-test"), "count=2147483647,bytes=9223372036854775807"); + setQuotaData(tree, Quotas.quotaPath("/quota-test"), "count=2147483647,bytes=9223372036854775807"); + DataTree.QuotaStats sample = sampleQuota(tree); + assertTrue(sample.isAvailable()); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), sample.getCountUsed()); + assertEquals(Long.valueOf(Long.MAX_VALUE), sample.getBytesUsed()); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), sample.getCountLimit()); + assertEquals(Long.valueOf(Long.MAX_VALUE), sample.getBytesLimit()); + setQuotaData(tree, Quotas.statPath("/quota-test"), "count=000,bytes=000"); + sample = sampleQuota(tree); + assertTrue(sample.isAvailable()); + assertEquals(Integer.valueOf(0), sample.getCountUsed()); + assertEquals(Long.valueOf(0), sample.getBytesUsed()); + } + + @Test(timeout = 30000) + public void testQuotaStatsConcurrentMetadataUpdates() throws Exception { + DataTree tree = new DataTree(); + createQuotaTree(tree); + setQuotaData(tree, Quotas.statPath("/quota-test"), "count=2,bytes=20"); + setQuotaData(tree, Quotas.quotaPath("/quota-test"), "count=5,bytes=50"); + CountDownLatch start = new CountDownLatch(1); + ExecutorService writer = Executors.newSingleThreadExecutor(); + try { + Future updates = writer.submit(() -> { + start.await(); + for (int i = 0; i < 1000; i++) { + setQuotaData(tree, Quotas.statPath("/quota-test"), i % 2 == 0 ? "count=3,bytes=30" : "count=2,bytes=20"); + setQuotaData(tree, Quotas.quotaPath("/quota-test"), i % 2 == 0 ? "count=7,bytes=70" : "count=5,bytes=50"); + } + return null; + }); + start.countDown(); + for (int i = 0; i < 1000; i++) { + DataTree.QuotaStats sample = sampleQuota(tree); + assertTrue(sample.isAvailable()); + assertTrue((sample.getCountUsed() == 2 && sample.getBytesUsed() == 20) + || (sample.getCountUsed() == 3 && sample.getBytesUsed() == 30)); + assertTrue((sample.getCountLimit() == 5 && sample.getBytesLimit() == 50) + || (sample.getCountLimit() == 7 && sample.getBytesLimit() == 70)); + } + updates.get(10, TimeUnit.SECONDS); + } finally { + writer.shutdownNow(); + assertTrue(writer.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + private void createQuotaTree(DataTree tree) throws Exception { + tree.createNode("/quota-test", new byte[3], null, 0, -1, 1, 1); + tree.createNode(Quotas.quotaZookeeper + "/quota-test", null, null, 0, -1, 1, 1); + tree.createNode(Quotas.quotaPath("/quota-test"), "count=10,bytes=100".getBytes(StandardCharsets.UTF_8), + null, 0, -1, 1, 1); + tree.createNode(Quotas.statPath("/quota-test"), new byte[0], null, 0, -1, 1, 1); + } + + private void setQuotaData(DataTree tree, String path, String value) throws NoNodeException { + tree.setData(path, value == null ? null : value.getBytes(StandardCharsets.UTF_8), 1, 2, 2); + } + + private DataTree.QuotaStats sampleQuota(DataTree tree) { + try { + return tree.getQuotaStats("/quota-test"); + } catch (RuntimeException e) { + throw new AssertionError("Quota telemetry must report unavailable, not throw " + e.getClass().getSimpleName()); + } + } + + private void assertQuotaUnavailable(DataTree.QuotaStats sample, String reason) { + assertFalse(sample.isAvailable()); + assertEquals(reason, sample.getReason()); + assertNull(sample.getCountUsed()); + assertNull(sample.getBytesUsed()); + assertNull(sample.getCountLimit()); + assertNull(sample.getBytesLimit()); + } + + private static class BoundedQuotaTree extends DataTree { + + private boolean sampling; + private int lookups; + + @Override + public DataNode getNode(String path) { + if (sampling) { + assertTrue("Unexpected subtree lookup", "/quota-test".equals(path) + || Quotas.statPath("/quota-test").equals(path) + || Quotas.quotaPath("/quota-test").equals(path)); + assertTrue("Quota telemetry must perform bounded lookups", ++lookups <= 6); + } + return super.getNode(path); + } + + @Override + public String getMaxPrefixWithQuota(String path) { + assertFalse("Quota telemetry must not search ancestor quotas", sampling); + return super.getMaxPrefixWithQuota(path); + } + + } + + private static class ChangingQuotaTree extends DataTree { + + private String changePath; + private boolean replace; + + @Override + public DataNode getNode(String path) { + DataNode node = super.getNode(path); + if (changePath != null && Quotas.quotaPath("/quota-test").equals(path)) { + String changed = changePath; + changePath = null; + byte[] data = super.getNode(changed).getData(); + try { + super.deleteNode(changed, 2); + if (replace) { + super.createNode(changed, data, null, 0, -1, 3, 3); + } + } catch (NoNodeException | NodeExistsException e) { + throw new AssertionError(e); + } + } + return node; + } + + } + /* ZOOKEEPER-3531 - org.apache.zookeeper.server.DataTree#serialize calls the aclCache.serialize when doing * dataree serialization, however, org.apache.zookeeper.server.ReferenceCountedACLCache#serialize diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/OfflineAuditExporterTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/OfflineAuditExporterTest.java new file mode 100644 index 00000000000..342ae1b109e --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/OfflineAuditExporterTest.java @@ -0,0 +1,939 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zookeeper.server; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.apache.jute.BinaryOutputArchive; +import org.apache.jute.OutputArchive; +import org.apache.zookeeper.Quotas; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.data.Stat; +import org.apache.zookeeper.proto.GetChildren2Response; +import org.apache.zookeeper.proto.GetChildrenResponse; +import org.apache.zookeeper.proto.ReplyHeader; +import org.apache.zookeeper.server.SnapshotComparerTest.SnapshotToolTestSupport; +import org.apache.zookeeper.server.persistence.FileHeader; +import org.apache.zookeeper.server.persistence.FileSnap; +import org.apache.zookeeper.server.persistence.SnapStream.StreamMode; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +public class OfflineAuditExporterTest extends ZKTestCase { + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String MAIN = "org.apache.zookeeper.server.OfflineAuditExporter"; + private SnapshotToolTestSupport support; + private int nextOutput; + + @Before + public void setUp() throws IOException { + support = new SnapshotToolTestSupport(); + } + + @After + public void tearDown() throws IOException { + support.close(); + } + + @Test + public void childResponseSizesIncludeProtocolOverhead() throws Exception { + List names = Arrays.asList("\u00e9", "a"); + assertEquals(31L, OfflineAuditExporter.childResponseBytes(names, false)); + assertEquals(99L, OfflineAuditExporter.childResponseBytes(names, true)); + assertEquals(20L, OfflineAuditExporter.childResponseBytes(Collections.emptyList(), false)); + assertEquals(88L, OfflineAuditExporter.childResponseBytes(Collections.emptyList(), true)); + assertEquals(28L, OfflineAuditExporter.childResponseBytes(Collections.singletonList("\ud83d\ude00"), false)); + } + + @Test + public void childResponseSizesMatchActualJuteSerialization() throws Exception { + for (List names : Arrays.asList(Collections.emptyList(), + Arrays.asList("a", "\u00e9", "\u4e2d", "quote\""))) { + for (boolean includeStat : new boolean[]{false, true}) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + OutputArchive archive = BinaryOutputArchive.getArchive(bytes); + new ReplyHeader(123, 456L, 0).serialize(archive, "header"); + if (includeStat) { + new GetChildren2Response(names, new Stat()).serialize(archive, "response"); + } else { + new GetChildrenResponse(names).serialize(archive, "response"); + } + assertEquals(bytes.size(), OfflineAuditExporter.childResponseBytes(names, includeStat)); + } + } + } + + @Test + public void childResponseSizesUseLongArithmetic() throws Exception { + char[] name = new char[65532]; + Arrays.fill(name, 'a'); + assertEquals(2147483668L, OfflineAuditExporter.childResponseBytes(Collections.nCopies(32768, new String(name)), false)); + } + + @Test + public void exportsRootUnicodeAndPersistedMetadataWithoutSecrets() throws Exception { + DataTree tree = customerTree(); + tree.setData("/app/\u00e9", "private-synthetic-data".getBytes(StandardCharsets.UTF_8), 2, 3, 1234); + DataNode unicode = tree.getNode("/app/\u00e9"); + unicode.stat.setCzxid(0x2000000000000001L); + unicode.stat.setMzxid(0x7ffffffffffffff0L); + unicode.stat.setPzxid(0x8000000000000001L); + unicode.stat.setCtime(1000); + tree.setACL("/app/\u00e9", Collections.singletonList( + new ACL(ZooDefs.Perms.READ, new Id("digest", "synthetic-user:do-not-export"))), 4); + File snapshot = support.snapshot(tree, StreamMode.CHECKED, true); + byte[] original = Files.readAllBytes(snapshot.toPath()); + FileTime mtime = Files.getLastModifiedTime(snapshot.toPath()); + Path output = output(); + + run(snapshot, output).success(); + + JsonNode root = node(output, "/"); + assertEquals(5L, root.get("data_length").asLong()); + assertEquals(4L, root.get("num_children").asLong()); + assertEquals(1L, root.get("path_utf8_bytes").asLong()); + JsonNode record = node(output, "/app/\u00e9"); + assertEquals(1, record.get("schema_version").asInt()); + assertEquals(22L, record.get("data_length").asLong()); + assertEquals(7L, record.get("path_utf8_bytes").asLong()); + assertEquals(0L, record.get("num_children").asLong()); + assertEquals(20L, record.get("getchildren_response_bytes").asLong()); + assertEquals(88L, record.get("getchildren2_response_bytes").asLong()); + assertEquals(1000L, record.get("ctime_ms").asLong()); + assertEquals(1234L, record.get("mtime_ms").asLong()); + assertEquals("0x2000000000000001", record.get("czxid").asText()); + assertEquals("0x7ffffffffffffff0", record.get("mzxid").asText()); + assertEquals("0x8000000000000001", record.get("pzxid").asText()); + assertEquals(2, record.get("data_version").asInt()); + assertEquals(4, record.get("acl_version").asInt()); + assertEquals(0, record.get("persisted_cversion").asInt()); + assertEquals("persistent", record.get("node_type").asText()); + assertEquals("0x0000000000000000", record.get("ephemeral_owner").asText()); + assertTrue(record.get("ttl_ms").isNull()); + assertEquals(9, records(output.resolve("nodes.ndjson")).size()); + String all = text(output.resolve("nodes.ndjson")); + assertThat(all, not(containsString("private-synthetic-data"))); + assertThat(all, not(containsString("synthetic-user"))); + assertThat(all, not(containsString("do-not-export"))); + assertThat(all, not(containsString("\"path\":\"\""))); + assertEquals("0x8000000000000001", manifest(output).get("last_observed_zxid").asText()); + assertArrayEquals(original, Files.readAllBytes(snapshot.toPath())); + assertEquals(mtime, Files.getLastModifiedTime(snapshot.toPath())); + } + + @Test + public void nodeOrderIsDeterministicPreorderWithContiguousSubtrees() throws Exception { + DataTree tree = new DataTree(); + for (String path : Arrays.asList("/a-", "/a-/y", "/a", "/a/x", "/a/x/z")) { + support.addNode(tree, path, null); + } + File snapshot = support.snapshot(tree, StreamMode.CHECKED, false); + Path first = output(); + Path second = output(); + run(snapshot, first).success(); + run(snapshot, second).success(); + List paths = new ArrayList<>(); + for (JsonNode node : records(first.resolve("nodes.ndjson"))) { + paths.add(node.get("path").asText()); + } + assertEquals(Arrays.asList("/", "/a", "/a/x", "/a/x/z", "/a-", "/a-/y", + "/zookeeper", "/zookeeper/config", "/zookeeper/quota"), paths); + assertArrayEquals(Files.readAllBytes(first.resolve("nodes.ndjson")), Files.readAllBytes(second.resolve("nodes.ndjson"))); + } + + @Test + public void namespaceTotalsAreInclusiveAndExcludeOnlyReservedSubtree() throws Exception { + DataTree tree = customerTree(); + addQuota(tree, "/app", "count=50,bytes=1000"); + Path output = output(); + + run(support.snapshot(tree, StreamMode.CHECKED, false), output).success(); + + assertEquals(3, records(output.resolve("namespaces.ndjson")).size()); + JsonNode app = record(output.resolve("namespaces.ndjson"), "path", "/app"); + assertEquals(3L, app.get("subtree_node_count").asLong()); + assertEquals(12L, app.get("payload_bytes").asLong()); + assertEquals(17L, app.get("path_utf8_bytes").asLong()); + assertEquals("valid", app.get("quota_status").asText()); + assertEquals(50, app.get("quota_limit_count").asInt()); + assertEquals(1000L, app.get("quota_limit_bytes").asLong()); + assertTrue(app.get("quota_stat_present").asBoolean()); + JsonNode client = record(output.resolve("namespaces.ndjson"), "path", "/zookeeper-client"); + assertEquals(1L, client.get("subtree_node_count").asLong()); + assertEquals(11L, client.get("payload_bytes").asLong()); + assertEquals(17L, client.get("path_utf8_bytes").asLong()); + assertEquals("absent", client.get("quota_status").asText()); + assertTrue(client.get("quota_limit_bytes").isNull()); + assertFalse(client.get("quota_stat_present").asBoolean()); + assertThat(text(output.resolve("namespaces.ndjson")), not(containsString("\"path\":\"/zookeeper\""))); + assertNotNull(node(output, "/zookeeper/quota/app/zookeeper_limits")); + } + + @Test + public void quotaAvailabilityDoesNotInventOrLeakMalformedLimits() throws Exception { + DataTree tree = customerTree(); + addQuota(tree, "/app", "private-malformed-quota"); + addQuota(tree, "/other", "count=-1,bytes=999"); + Path output = output(); + + run(support.snapshot(tree, StreamMode.CHECKED, false), output).success(); + + JsonNode invalid = record(output.resolve("namespaces.ndjson"), "path", "/app"); + assertEquals("invalid", invalid.get("quota_status").asText()); + assertTrue(invalid.get("quota_limit_count").isNull()); + assertTrue(invalid.get("quota_limit_bytes").isNull()); + JsonNode valid = record(output.resolve("namespaces.ndjson"), "path", "/other"); + assertEquals("valid", valid.get("quota_status").asText()); + assertEquals(-1, valid.get("quota_limit_count").asInt()); + assertEquals(999, valid.get("quota_limit_bytes").asLong()); + assertThat(text(output.resolve("namespaces.ndjson")), not(containsString("private-malformed-quota"))); + } + + @Test + public void aclFlagsAreReviewHintsWithoutRawIdentities() throws Exception { + DataTree tree = new DataTree(); + List> acls = Arrays.asList( + ZooDefs.Ids.OPEN_ACL_UNSAFE, + Collections.singletonList(new ACL(ZooDefs.Perms.READ, new Id("world", "anyone"))), + Collections.singletonList(new ACL(ZooDefs.Perms.READ, new Id("auth", ""))), + Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("custom-secret-scheme", "private-id"))), + Collections.emptyList(), + Collections.singletonList(new ACL(64, new Id("digest", "private-digest"))), + Collections.singletonList(new ACL(ZooDefs.Perms.READ, new Id("world", "private-invalid-identity")))); + List> expected = Arrays.asList( + flags("world_read", "world_write", "world_admin"), flags("world_read"), + flags("auth"), flags("unknown_scheme"), flags("missing_acl"), flags("invalid_permissions"), flags("invalid_identity")); + for (int i = 0; i < acls.size(); i++) { + tree.createNode("/acl" + i, null, acls.get(i), 0, -1, 1, 1); + } + Path output = output(); + + run(support.snapshot(tree, StreamMode.CHECKED, false), output).success(); + + for (int i = 0; i < expected.size(); i++) { + Set actual = new HashSet<>(); + for (JsonNode flag : node(output, "/acl" + i).get("acl_risk_flags")) { + actual.add(flag.asText()); + } + assertEquals(expected.get(i), actual); + assertEquals(0, node(output, "/acl" + i).get("data_length").asLong()); + } + assertThat(text(output.resolve("nodes.ndjson")), not(containsString("private-"))); + assertThat(text(output.resolve("nodes.ndjson")), not(containsString("custom-secret-scheme"))); + } + + @Test + public void unknownConfigurationDoesNotTurnTtlOrUnknownOwnersIntoSessions() throws Exception { + Path output = output(); + run(support.snapshot(ownerTree(true), StreamMode.CHECKED, true), output).success(); + + assertType(output, "/session", "ephemeral", "session"); + assertType(output, "/container", "container", "container"); + for (String path : Arrays.asList("/ttl", "/legacy", "/unknown")) { + assertType(output, path, "unknown", "unknown"); + } + List sessions = records(output.resolve("sessions.ndjson")); + assertEquals(1, sessions.size()); + JsonNode session = sessions.get(0); + assertEquals("0x0000000000000123", session.get("session_id").asText()); + assertEquals(2, session.get("ephemeral_node_count").asLong()); + assertEquals(12, session.get("payload_bytes").asLong()); + assertEquals(15, session.get("path_utf8_bytes").asLong()); + assertTrue(session.get("present_in_snapshot_session_table").asBoolean()); + assertEquals(30000, session.get("timeout_ms").asInt()); + assertEquals(3, manifest(output).get("unknown_owner_nodes").asLong()); + assertTrue(manifest(output).get("decoder").get("source_extended_types_enabled").isNull()); + } + + @Test + public void explicitModernConfigurationDecodesTtlAndContainer() throws Exception { + Path output = output(); + run(support.snapshot(ownerTree(false), StreamMode.CHECKED, true), output, + Arrays.asList("-Dzookeeper.extendedTypesEnabled=true", "-Dzookeeper.emulate353TTLNodes=false")).success(); + + assertType(output, "/ttl", "ttl", "ttl"); + assertEquals(42, node(output, "/ttl").get("ttl_ms").asLong()); + assertType(output, "/legacy", "ephemeral", "session"); + assertType(output, "/container", "container", "container"); + assertEquals(2, records(output.resolve("sessions.ndjson")).size()); + JsonNode orphan = record(output.resolve("sessions.ndjson"), "session_id", "0x800000000000002a"); + assertFalse(orphan.get("present_in_snapshot_session_table").asBoolean()); + assertTrue(orphan.get("timeout_ms").isNull()); + assertTrue(manifest(output).get("decoder").get("source_extended_types_enabled").asBoolean()); + assertFalse(manifest(output).get("decoder").get("source_emulate_353_ttl_nodes").asBoolean()); + } + + @Test + public void explicitLegacyConfigurationUsesNativeTtlValueSemantics() throws Exception { + DataTree tree = ownerTree(false); + tree.getNode("/legacy").stat.setEphemeralOwner(0x800001000000002aL); + Path output = output(); + run(support.snapshot(tree, StreamMode.CHECKED, false), output, + Arrays.asList("-Dzookeeper.extendedTypesEnabled=true", "-Dzookeeper.emulate353TTLNodes=true")).success(); + + assertType(output, "/legacy", "ttl", "ttl-3.5.3"); + assertEquals(42, node(output, "/legacy").get("ttl_ms").asLong()); + assertEquals(1, records(output.resolve("sessions.ndjson")).size()); + } + + @Test + public void explicitDisabledExtendedTypesPreservesNegativeSessionIds() throws Exception { + Path output = output(); + run(support.snapshot(ownerTree(true), StreamMode.CHECKED, false), output, + Collections.singletonList("-Dzookeeper.extendedTypesEnabled=false")).success(); + + assertType(output, "/ttl", "ephemeral", "session"); + assertType(output, "/unknown", "ephemeral", "session"); + assertType(output, "/container", "container", "container"); + assertEquals(4, records(output.resolve("sessions.ndjson")).size()); + } + + @Test + public void missingLegacySettingRemainsUnknownForNegativeEncodings() throws Exception { + Path output = output(); + run(support.snapshot(ownerTree(false), StreamMode.CHECKED, false), output, + Collections.singletonList("-Dzookeeper.extendedTypesEnabled=true")).success(); + + assertType(output, "/legacy", "unknown", "unknown"); + assertType(output, "/ttl", "unknown", "unknown"); + assertEquals(1, records(output.resolve("sessions.ndjson")).size()); + } + + @Test + public void unsupportedKnownExtendedEncodingFailsClosed() throws Exception { + Path output = output(); + failure(run(support.snapshot(ownerTree(true), StreamMode.CHECKED, false), output, + Arrays.asList("-Dzookeeper.extendedTypesEnabled=true", "-Dzookeeper.emulate353TTLNodes=false"))); + assertFalse(Files.exists(output.resolve("manifest.json"))); + } + + @Test + public void invalidDecoderPropertiesAreNotSilentlyDefaulted() throws Exception { + File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, false); + for (List properties : Arrays.asList( + Collections.singletonList("-Dzookeeper.extendedTypesEnabled=maybe"), + Collections.singletonList("-Dzookeeper.emulate353TTLNodes=yes"), + Collections.singletonList("-Dzookeeper.emulate353TTLNodes=true"), + Collections.singletonList("-Djute.maxbuffer=not-a-number"), + Collections.singletonList("-Djute.maxbuffer=-1"), + Arrays.asList("-Djute.maxbuffer=2147483647", "-Dzookeeper.jute.maxbuffer.extrasize=1024"), + Collections.singletonList("-Dzookeeper.jute.maxbuffer.extrasize=-1"))) { + Path output = output(); + run(snapshot, output, properties).invalidInvocation(); + assertFalse(Files.exists(output)); + } + } + + @Test + public void nativeJutePropertySyntaxAndMinimumExtraPaddingAreRecorded() throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/large", new byte[8000]); + File snapshot = support.snapshot(tree, StreamMode.CHECKED, false); + Path output = output(); + run(snapshot, output, Arrays.asList("-Djute.maxbuffer=0x1000", "-Dzookeeper.jute.maxbuffer.extrasize=010000")).success(); + assertEquals(8000, node(output, "/large").get("data_length").asLong()); + assertEquals(4096, manifest(output).get("decoder").get("jute_maxbuffer").asInt()); + assertEquals(4096, manifest(output).get("decoder").get("jute_extra_maxbuffer").asInt()); + File small = support.snapshot(new DataTree(), StreamMode.CHECKED, false); + Path padded = output(); + run(small, padded, Arrays.asList("-Djute.maxbuffer=1024", "-Dzookeeper.jute.maxbuffer.extrasize=0")).success(); + assertEquals(1024, manifest(padded).get("decoder").get("jute_extra_maxbuffer").asInt()); + } + + @Test + public void manifestRecordsChecksumsAndUnknownCaptureRatherThanRecoveryClaims() throws Exception { + for (StreamMode mode : StreamMode.values()) { + for (boolean digest : new boolean[]{false, true}) { + File snapshot = support.snapshot(customerTree(), mode, digest); + Path output = output(); + run(snapshot, output).success(); + JsonNode manifest = manifest(output); + assertEquals(1, manifest.get("schema_version").asInt()); + assertTrue(manifest.get("export_complete").asBoolean()); + assertEquals("snapshot-only", manifest.get("recovery_scope").asText()); + assertFalse(manifest.get("transaction_logs_replayed").asBoolean()); + assertTrue(manifest.get("source_capture_time_ms").isNull()); + assertTrue(manifest.get("source_capture_provenance").isNull()); + assertTrue(manifest.get("source_server_version").isNull()); + assertTrue(manifest.get("max_output_bytes").isNull()); + assertEquals(snapshot.getCanonicalPath(), manifest.get("source").get("path").asText()); + assertEquals(Files.size(snapshot.toPath()), manifest.get("source").get("size_bytes").asLong()); + assertEquals(snapshot.lastModified(), manifest.get("source").get("mtime_ms").asLong()); + assertEquals(sha256(snapshot.toPath()), manifest.get("source").get("sha256").asText()); + assertEquals(2, manifest.get("source").get("format_version").asInt()); + assertEquals("OfflineAuditExporter", manifest.get("tool").get("name").asText()); + assertFalse(manifest.get("tool").get("version").asText().isEmpty()); + assertTrue(manifest.get("snapshot_zxid").asText().startsWith("0x")); + assertEquals(!digest, manifest.get("snapshot_digest").isNull()); + if (digest) { + assertTrue(manifest.get("snapshot_digest").get("seal_validated").asBoolean()); + assertFalse(manifest.get("snapshot_digest").get("transaction_consistency_verified").asBoolean()); + } + Set names = new HashSet<>(); + for (JsonNode descriptor : manifest.get("files")) { + Path file = output.resolve(descriptor.get("name").asText()); + names.add(file.getFileName().toString()); + assertEquals(Files.size(file), descriptor.get("size_bytes").asLong()); + assertEquals(sha256(file), descriptor.get("sha256").asText()); + assertEquals(records(file).size(), descriptor.get("records").asLong()); + } + assertEquals(flags("nodes.ndjson", "namespaces.ndjson", "sessions.ndjson"), names); + } + } + } + + @Test + public void fuzzyDigestDoesNotRequireTransactionLogsOrBecomeAnEndpoint() throws Exception { + DataTree tree = new DataTree() { + @Override + public boolean serializeZxidDigest(OutputArchive archive) throws IOException { + new ZxidDigest(0x123456789abcdef0L, 2, 0xfedcba9876543210L).serialize(archive); + return true; + } + }; + support.addNode(tree, "/customer", new byte[3]); + File snapshot = support.snapshot(tree, StreamMode.CHECKED, true); + Path output = output(); + + run(snapshot, output).success(); + + JsonNode manifest = manifest(output); + assertEquals("0x0000000000000001", manifest.get("last_observed_zxid").asText()); + assertEquals("0x123456789abcdef0", manifest.get("snapshot_digest").get("zxid").asText()); + assertEquals("0xfedcba9876543210", manifest.get("snapshot_digest").get("value").asText()); + Path renamed = snapshot.toPath().resolveSibling("snapshot.11-22"); + Files.copy(snapshot.toPath(), renamed); + Path renamedOutput = output(); + run(renamed.toFile(), renamedOutput).success(); + assertTrue(manifest(renamedOutput).get("snapshot_zxid").isNull()); + } + + @Test + public void rejectsCorruptionWithoutFallbackToAnotherSnapshot() throws Exception { + support.snapshot(customerTree(), StreamMode.CHECKED, true); + for (File snapshot : support.corruptSnapshots()) { + Path output = output(); + failure(run(snapshot, output)); + assertFalse(Files.exists(output.resolve("manifest.json"))); + } + } + + @Test + public void rejectsSealedUnsupportedFormatAndMissingRoot() throws Exception { + File unsupported = new File(support.directory, "snapshot.abc"); + new FileSnap(null) { + @Override + protected void serialize(DataTree tree, Map sessions, OutputArchive archive, + FileHeader header) throws IOException { + super.serialize(tree, sessions, archive, new FileHeader(FileSnap.SNAP_MAGIC, 3, -1)); + } + }.serialize(new DataTree(), Collections.emptyMap(), unsupported, false); + Path unsupportedOutput = output(); + failure(run(unsupported, unsupportedOutput)); + assertFalse(Files.exists(unsupportedOutput.resolve("manifest.json"))); + DataTree missingRoot = new DataTree() { + @Override + public void serializeNodes(OutputArchive archive) throws IOException { + archive.writeString("/", "path"); + } + }; + Path rootOutput = output(); + failure(run(support.snapshot(missingRoot, StreamMode.CHECKED, false), rootOutput)); + assertFalse(Files.exists(rootOutput.resolve("manifest.json"))); + } + + @Test + public void rejectsSealedDuplicateNodesInsteadOfDoubleCountingOwners() throws Exception { + DataTree tree = new DataTree() { + @Override + public void serializeNodes(OutputArchive archive) throws IOException { + for (String path : Arrays.asList("", "/zookeeper", "/zookeeper/config", "/zookeeper/quota", "/same")) { + serializeNodeData(archive, path, getNode(path)); + } + DataNode duplicate = new DataNode(new byte[7], getNode("/same").acl, createStat(2, 2, 0x456L)); + serializeNodeData(archive, "/same", duplicate); + archive.writeString("/", "path"); + } + }; + tree.createNode("/same", new byte[5], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1); + Path output = output(); + failure(run(support.snapshot(tree, StreamMode.CHECKED, true), output)); + assertFalse(Files.exists(output.resolve("manifest.json"))); + } + + @Test + public void rejectsInvalidCliArgumentsBeforeCreatingOutput() throws Exception { + File snapshot = support.snapshot(customerTree(), StreamMode.CHECKED, false); + execute(Collections.emptyList()).invalidInvocation(); + execute(Collections.emptyList(), "--snapshot-file", snapshot.toString()).invalidInvocation(); + for (String limit : Arrays.asList("0", "-1", "NaN", "9223372036854775808")) { + Path output = output(); + run(snapshot, output, Collections.emptyList(), "--max-output-bytes", limit).invalidInvocation(); + assertFalse(Files.exists(output)); + } + Path output = output(); + run(snapshot, output, Collections.emptyList(), "unexpected").invalidInvocation(); + run(snapshot, output, Collections.emptyList(), "--unknown").invalidInvocation(); + run(snapshot, output, Collections.emptyList(), + "--snapshot-file", snapshot.toString()).invalidInvocation(); + run(new File(support.directory, "missing"), output).invalidInvocation(); + run(support.directory, output).invalidInvocation(); + assertFalse(Files.exists(output)); + } + + @Test + public void rejectsExistingOutputAndAliasesWithoutTouchingUnrelatedFiles() throws Exception { + File snapshot = support.snapshot(customerTree(), StreamMode.CHECKED, true); + byte[] original = Files.readAllBytes(snapshot.toPath()); + Path output = output(); + run(snapshot, output).success(); + byte[] priorManifest = Files.readAllBytes(output.resolve("manifest.json")); + run(snapshot, output).invalidInvocation(); + assertArrayEquals(priorManifest, Files.readAllBytes(output.resolve("manifest.json"))); + run(snapshot, snapshot.toPath()).invalidInvocation(); + run(snapshot, snapshot.toPath().getParent()).invalidInvocation(); + Path existing = output(); + Files.createDirectory(existing); + Path unrelated = existing.resolve("unrelated.txt"); + Files.write(unrelated, new byte[]{1, 2, 3}); + run(snapshot, existing).invalidInvocation(); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(unrelated)); + Path link = support.directory.toPath().resolve("source-hardlink"); + Files.createLink(link, snapshot.toPath()); + run(snapshot, link).invalidInvocation(); + assertArrayEquals(original, Files.readAllBytes(snapshot.toPath())); + } + + @Test + public void rejectsSymbolicOutputAliases() throws Exception { + Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows")); + File snapshot = support.snapshot(customerTree(), StreamMode.CHECKED, true); + Path link = support.directory.toPath().resolve("output-link"); + Path dangling = support.directory.toPath().resolve("dangling-link"); + try { + Files.createSymbolicLink(link, snapshot.toPath().getParent()); + run(snapshot, link).invalidInvocation(); + Files.createSymbolicLink(dangling, support.directory.toPath().resolve("missing-target")); + run(snapshot, dangling).invalidInvocation(); + assertTrue(Files.isSymbolicLink(dangling)); + } finally { + Files.deleteIfExists(link); + Files.deleteIfExists(dangling); + } + } + + @Test + public void symlinkParentComponentsDoNotSelectADifferentSnapshotOrDestination() throws Exception { + Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows")); + Path real = support.directory.toPath().resolve("real"); + Files.createDirectories(real.resolve("inner")); + Path alias = support.directory.toPath().resolve("alias"); + DataTree selectedTree = new DataTree(); + support.addNode(selectedTree, "/selected", new byte[7]); + Path selected = real.resolve("snapshot.55"); + Files.copy(support.snapshot(selectedTree, StreamMode.CHECKED, false).toPath(), selected); + DataTree decoy = new DataTree(); + support.addNode(decoy, "/selected", new byte[17]); + Files.copy(support.snapshot(decoy, StreamMode.CHECKED, false).toPath(), + support.directory.toPath().resolve("snapshot.55")); + try { + Files.createSymbolicLink(alias, real.resolve("inner")); + Path input = alias.resolve("../snapshot.55"); + Path output = alias.resolve("../export"); + run(input.toFile(), output).success(); + assertTrue(Files.exists(real.resolve("export/manifest.json"))); + assertEquals(selected.toRealPath().toString(), manifest(output).get("source").get("path").asText()); + assertEquals(7, node(output, "/selected").get("data_length").asLong()); + assertFalse(Files.exists(support.directory.toPath().resolve("export"))); + } finally { + Files.deleteIfExists(alias); + } + } + + @Test + public void outputBudgetFailuresNeverPublishSuccessEvenAfterAllDataFiles() throws Exception { + File snapshot = support.snapshot(customerTree(), StreamMode.CHECKED, false); + Path complete = output(); + run(snapshot, complete).success(); + long dataBytes = 0; + for (JsonNode file : manifest(complete).get("files")) { + dataBytes += file.get("size_bytes").asLong(); + } + for (long limit : new long[]{1, 1500, dataBytes + 1}) { + Path output = output(); + failure(run(snapshot, output, Collections.emptyList(), + "--max-output-bytes", Long.toString(limit))); + assertFalse(Files.exists(output.resolve("manifest.json"))); + assertTrue(Files.exists(output.resolve("nodes.ndjson"))); + assertTrue(Files.size(output.resolve("nodes.ndjson")) <= limit); + if (limit == dataBytes + 1) { + assertTrue(Files.exists(output.resolve("sessions.ndjson"))); + assertEquals(Files.size(complete.resolve("nodes.ndjson")), Files.size(output.resolve("nodes.ndjson"))); + } + run(snapshot, output).invalidInvocation(); + } + Path bounded = output(); + run(snapshot, bounded, Collections.emptyList(), "--max-output-bytes", "1000000").success(); + assertEquals(1000000L, manifest(bounded).get("max_output_bytes").asLong()); + } + + @Test + public void largePayloadHonorsNativeJuteDecoderLimits() throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/large", new byte[3000000]); + File snapshot = support.snapshot(tree, StreamMode.CHECKED, false); + Path tooSmall = output(); + failure(run(snapshot, tooSmall)); + assertFalse(Files.exists(tooSmall.resolve("manifest.json"))); + Path output = output(); + run(snapshot, output, Collections.singletonList("-Djute.maxbuffer=4194304")).success(); + assertEquals(3000000L, node(output, "/large").get("data_length").asLong()); + assertEquals(4194304, manifest(output).get("decoder").get("jute_maxbuffer").asInt()); + assertEquals(4194304, manifest(output).get("decoder").get("jute_extra_maxbuffer").asInt()); + assertTrue(Files.size(output.resolve("nodes.ndjson")) < 10000); + } + + @Test + public void largeFanoutReportsImmediateChildrenAndCompleteNamespaceTotals() throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/fanout", null); + for (int i = 0; i < 4096; i++) { + support.addNode(tree, String.format("/fanout/child-%04d", i), new byte[1]); + } + Path output = output(); + run(support.snapshot(tree, StreamMode.CHECKED, false), output).success(); + JsonNode node = node(output, "/fanout"); + assertEquals(4096, node.get("num_children").asLong()); + assertEquals(57364L, node.get("getchildren_response_bytes").asLong()); + assertEquals(57432L, node.get("getchildren2_response_bytes").asLong()); + JsonNode namespace = record(output.resolve("namespaces.ndjson"), "path", "/fanout"); + assertEquals(4097L, namespace.get("subtree_node_count").asLong()); + assertEquals(4096L, namespace.get("payload_bytes").asLong()); + assertEquals(73735L, namespace.get("path_utf8_bytes").asLong()); + } + + @Test + public void outputFilesystemFailureDoesNotPublishAResult() throws Exception { + Assume.assumeTrue(support.directory.toPath().getFileSystem().supportedFileAttributeViews().contains("posix")); + Path parent = support.directory.toPath().resolve("unwritable"); + Files.createDirectory(parent); + File snapshot = support.snapshot(customerTree(), StreamMode.CHECKED, false); + try { + Files.setPosixFilePermissions(parent, PosixFilePermissions.fromString("r-x------")); + Assume.assumeFalse(Files.isWritable(parent)); + Path output = parent.resolve("export"); + failure(run(snapshot, output)); + assertFalse(Files.exists(output.resolve("manifest.json"))); + } finally { + Files.setPosixFilePermissions(parent, PosixFilePermissions.fromString("rwx------")); + } + } + + @Test + public void rejectsInvalidPersistedPathsRatherThanPublishingNormalizedNames() throws Exception { + for (String path : Arrays.asList("/bad\nname", "/bad\u0000name", "/bad\ud83d\ude00")) { + DataTree tree = new DataTree(); + support.addNode(tree, path, null); + Path output = output(); + failure(run(support.snapshot(tree, StreamMode.CHECKED, false), output)); + assertFalse(Files.exists(output.resolve("manifest.json"))); + } + } + + @Test + public void traversalHandlesDeepTreesWithoutRecursiveExporterStack() throws Exception { + DataTree tree = new DataTree() { + @Override + public void serializeNodes(OutputArchive archive) throws IOException { + Deque paths = new ArrayDeque<>(); + paths.push(""); + while (!paths.isEmpty()) { + String path = paths.pop(); + DataNode node = getNode(path); + serializeNodeData(archive, path, node); + for (String child : node.getChildren()) { + paths.push(path + "/" + child); + } + } + archive.writeString("/", "path"); + } + }; + String path = ""; + for (int i = 0; i < 1800; i++) { + path += "/d"; + support.addNode(tree, path, new byte[1]); + } + Path output = output(); + + run(support.snapshot(tree, StreamMode.CHECKED, false), output, + Collections.singletonList("-Xss256k")).success(); + + JsonNode namespace = record(output.resolve("namespaces.ndjson"), "path", "/d"); + assertEquals(1800, namespace.get("subtree_node_count").asLong()); + assertEquals(1800, namespace.get("payload_bytes").asLong()); + assertEquals(3241800L, namespace.get("path_utf8_bytes").asLong()); + assertEquals(1804L, manifest(output).get("files").get(0).get("records").asLong()); + } + + @Test + public void detectsSourceByteChangesEvenWhenSizeAndMtimeAreRestored() throws Exception { + sourceMutation(0); + } + + @Test + public void detectsSourceReplacementEvenWhenBytesAndMtimeAreIdentical() throws Exception { + sourceMutation(1); + } + + @Test + public void detectsSourceRewriteThenRestoreWhenChangeTimeIsAvailable() throws Exception { + Assume.assumeTrue(support.directory.toPath().getFileSystem().supportedFileAttributeViews().contains("unix")); + sourceMutation(2); + } + + @Test + public void shellLauncherForwardsPathsAndJvmFlags() throws Exception { + Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows")); + File snapshot = support.snapshot(ownerTree(false), StreamMode.GZIP, true); + for (String flags : Arrays.asList("", "-Xms32m -Xmx128m -Dzookeeper.extendedTypesEnabled=true" + + " -Dzookeeper.emulate353TTLNodes=false")) { + Path output = output(); + support.runLauncher("zkOfflineAudit.sh", flags, "--snapshot-file", snapshot.toString(), + "--output-dir", output.toString()).success(); + assertType(output, "/ttl", flags.isEmpty() ? "unknown" : "ttl", flags.isEmpty() ? "unknown" : "ttl"); + } + } + + private void sourceMutation(int action) throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/fanout", null); + for (int i = 0; i < 12000; i++) { + support.addNode(tree, "/fanout/child-" + i, new byte[1]); + } + File snapshot = support.snapshot(tree, StreamMode.CHECKED, true); + byte[] bytes = Files.readAllBytes(snapshot.toPath()); + FileTime mtime = Files.getLastModifiedTime(snapshot.toPath()); + Path output = output(); + Path log = support.directory.toPath().resolve("mutation-log-" + nextOutput); + Process process = process(Collections.emptyList(), "--snapshot-file", snapshot.toString(), + "--output-dir", output.toString()).redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (!Files.exists(output.resolve("nodes.ndjson")) && process.isAlive() && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertTrue("Exporter never started writing: " + text(log), Files.exists(output.resolve("nodes.ndjson"))); + if (action == 1) { + Path replacement = support.directory.toPath().resolve("replacement"); + Files.write(replacement, bytes); + Files.setLastModifiedTime(replacement, mtime); + Files.move(replacement, snapshot.toPath(), StandardCopyOption.REPLACE_EXISTING); + } else { + bytes[bytes.length - 1] ^= 1; + Files.write(snapshot.toPath(), bytes); + if (action == 2) { + bytes[bytes.length - 1] ^= 1; + Files.write(snapshot.toPath(), bytes); + } + Files.setLastModifiedTime(snapshot.toPath(), mtime); + } + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + assertEquals(text(log), 1, process.exitValue()); + assertThat(text(log), containsString("Source changed")); + assertFalse(Files.exists(output.resolve("manifest.json"))); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + } + + private DataTree customerTree() throws Exception { + DataTree tree = new DataTree(); + tree.setData("/", new byte[5], 1, 1, 1); + support.addNode(tree, "/app", new byte[2]); + support.addNode(tree, "/app/\u00e9", new byte[3]); + tree.createNode("/app/e", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1); + support.addNode(tree, "/other", new byte[4]); + support.addNode(tree, "/zookeeper-client", new byte[11]); + return tree; + } + + private DataTree ownerTree(boolean unknown) throws Exception { + String previous = System.getProperty(EphemeralType.EXTENDED_TYPES_ENABLED_PROPERTY); + System.setProperty(EphemeralType.EXTENDED_TYPES_ENABLED_PROPERTY, "false"); + try { + DataTree tree = new DataTree(); + tree.createNode("/session", new byte[5], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1); + tree.createNode("/second", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1); + tree.createNode("/container", new byte[2], ZooDefs.Ids.OPEN_ACL_UNSAFE, Long.MIN_VALUE, -1, 1, 1); + tree.createNode("/ttl", new byte[3], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0xff0000000000002aL, -1, 1, 1); + tree.createNode("/legacy", new byte[4], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x800000000000002aL, -1, 1, 1); + if (unknown) { + tree.createNode("/unknown", new byte[6], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0xff0001000000002aL, -1, 1, 1); + } + return tree; + } finally { + if (previous == null) { + System.clearProperty(EphemeralType.EXTENDED_TYPES_ENABLED_PROPERTY); + } else { + System.setProperty(EphemeralType.EXTENDED_TYPES_ENABLED_PROPERTY, previous); + } + } + } + + private void addQuota(DataTree tree, String path, String limits) throws Exception { + support.addNode(tree, Quotas.quotaZookeeper + path, null); + support.addNode(tree, Quotas.quotaPath(path), limits.getBytes(StandardCharsets.UTF_8)); + support.addNode(tree, Quotas.statPath(path), "count=0,bytes=0".getBytes(StandardCharsets.UTF_8)); + } + + private Path output() { + return support.directory.toPath().resolve("export with spaces " + ++nextOutput); + } + + private SnapshotToolTestSupport.Result run(File snapshot, Path output) throws Exception { + return run(snapshot, output, Collections.emptyList()); + } + + private SnapshotToolTestSupport.Result run(File snapshot, Path output, List properties, + String... extra) throws Exception { + List args = new ArrayList<>(Arrays.asList("--snapshot-file", snapshot.toString(), + "--output-dir", output.toString())); + args.addAll(Arrays.asList(extra)); + return execute(properties, args.toArray(new String[0])); + } + + private SnapshotToolTestSupport.Result execute(List properties, String... args) throws Exception { + Path log = support.directory.toPath().resolve("cli-log-" + ++nextOutput); + Process process = process(properties, args).redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue("CLI timed out", process.waitFor(30, TimeUnit.SECONDS)); + return new SnapshotToolTestSupport.Result(process.exitValue(), text(log)); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + } + + private ProcessBuilder process(List properties, String... args) { + List command = new ArrayList<>(Arrays.asList( + new File(System.getProperty("java.home"), "bin/java").toString(), "-Xmx128m", + "-Djava.io.tmpdir=" + System.getProperty("java.io.tmpdir"))); + command.addAll(properties); + command.addAll(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), MAIN)); + command.addAll(Arrays.asList(args)); + return new ProcessBuilder(command); + } + + private static void failure(SnapshotToolTestSupport.Result result) { + assertEquals(result.output, 1, result.exitCode); + assertThat(result.output, containsString("Unable to export snapshot")); + } + + private static JsonNode manifest(Path output) throws IOException { + return JSON.readTree(output.resolve("manifest.json").toFile()); + } + + private static JsonNode node(Path output, String path) throws IOException { + return record(output.resolve("nodes.ndjson"), "path", path); + } + + private static JsonNode record(Path file, String key, String value) throws IOException { + for (JsonNode record : records(file)) { + if (record.get(key).asText().equals(value)) { + return record; + } + } + throw new AssertionError("Missing " + value + " in " + file); + } + + private static List records(Path file) throws IOException { + List records = new ArrayList<>(); + try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + records.add(JSON.readTree(line)); + } + } + return records; + } + + private static void assertType(Path output, String path, String type, String encoding) throws IOException { + JsonNode record = node(output, path); + assertEquals(path, type, record.get("node_type").asText()); + assertEquals(path, encoding, record.get("owner_encoding").asText()); + } + + private static Set flags(String... flags) { + return new HashSet<>(Arrays.asList(flags)); + } + + private static String text(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static String sha256(Path path) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path)); + StringBuilder result = new StringBuilder(); + for (byte value : digest) { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotComparerTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotComparerTest.java new file mode 100644 index 00000000000..f44c75893b2 --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotComparerTest.java @@ -0,0 +1,434 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zookeeper.server; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.FileUtils; +import org.apache.jute.BinaryInputArchive; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.server.persistence.FileSnap; +import org.apache.zookeeper.server.persistence.SnapStream; +import org.apache.zookeeper.server.persistence.SnapStream.StreamMode; +import org.apache.zookeeper.test.ClientBase; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +public class SnapshotComparerTest extends ZKTestCase { + + private SnapshotToolTestSupport support; + + @Before + public void setUp() throws IOException { + support = new SnapshotToolTestSupport(); + } + + @After + public void tearDown() throws IOException { + support.close(); + } + + @Test + public void testAddedAndDeletedSubtrees() throws Exception { + DataTree left = new DataTree(); + support.addNode(left, "/app", new byte[1]); + support.addNode(left, "/app/removed", new byte[2]); + support.addNode(left, "/app/removed/leaf", new byte[3]); + DataTree right = new DataTree(); + support.addNode(right, "/app", new byte[1]); + support.addNode(right, "/app/added", new byte[4]); + support.addNode(right, "/app/added/a", new byte[5]); + support.addNode(right, "/app/added/b", null); + + String output = compare(left, right, "0", "0", "-d"); + + assertThat(output, containsString("Node found in both trees. Delta: 4 bytes, 1 descendants")); + assertThat(output, containsString("Node /app found in both trees. Delta: 4 bytes, 1 descendants")); + assertThat(output, containsString("Node /app/removed found only in left tree. Descendant size: 5. Descendant count: 1")); + assertThat(output, containsString("Node /app/removed/leaf found only in left tree. Descendant size: 3. Descendant count: 0")); + assertThat(output, containsString("Node /app/added found only in right tree. Descendant size: 9. Descendant count: 2")); + assertThat(output, containsString("Node /app/added/a found only in right tree. Descendant size: 5. Descendant count: 0")); + assertThat(output, containsString("Filtered right node /app/added/b of size 0")); + assertTrue(output, output.indexOf("Node /app/added found") < output.indexOf("Node /app/removed found")); + assertThat(output, containsString("All layers compared.")); + } + + @Test + public void testSignedByteDeltas() throws Exception { + DataTree left = new DataTree(); + support.addNode(left, "/grow", new byte[1]); + support.addNode(left, "/shrink", new byte[4]); + DataTree right = new DataTree(); + support.addNode(right, "/grow", new byte[4]); + support.addNode(right, "/shrink", new byte[1]); + + String output = compare(left, right, "0", "2147483647"); + + assertThat(output, containsString("Node /grow found in both trees. Delta: 3 bytes, 0 descendants")); + assertThat(output, containsString("Node /shrink found in both trees. Delta: -3 bytes, 0 descendants")); + } + + @Test + public void testByteThresholdIsExclusive() throws Exception { + DataTree left = new DataTree(); + support.addNode(left, "/node", new byte[1]); + DataTree right = new DataTree(); + support.addNode(right, "/node", new byte[5]); + + String filtered = compare(left, right, "4", "0", "--debug"); + String included = compare(left, right, "3", "0"); + + assertThat(filtered, containsString("Filtered node /node of left size 1, right size 5")); + assertThat(filtered, not(containsString("Node /node found"))); + assertThat(included, containsString("Node /node found in both trees. Delta: 4 bytes, 0 descendants")); + } + + @Test + public void testDescendantThresholdWithNullData() throws Exception { + DataTree left = new DataTree(); + support.addNode(left, "/app", null); + DataTree right = new DataTree(); + support.addNode(right, "/app", null); + support.addNode(right, "/app/child", null); + + String filtered = compare(left, right, "2147483647", "1", "-d"); + String included = compare(left, right, "2147483647", "0"); + String removed = compare(right, left, "2147483647", "0"); + + assertThat(filtered, containsString("Filtered node /app of left size 0, right size 0")); + assertThat(filtered, not(containsString("Node /app found"))); + assertThat(included, containsString("Node /app found in both trees. Delta: 0 bytes, 1 descendants")); + assertThat(removed, containsString("Node /app found in both trees. Delta: 0 bytes, -1 descendants")); + } + + @Test + public void testEphemeralNodesAreIncluded() throws Exception { + DataTree right = new DataTree(); + right.createNode("/ephemeral", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1); + + String output = compare(new DataTree(), right, "0", "0"); + + assertThat(output, containsString("Node /ephemeral found only in right tree. Descendant size: 7. Descendant count: 0")); + assertThat(output, containsString("Node found in both trees. Delta: 7 bytes, 1 descendants")); + } + + @Test + public void testEqualLengthPayloadsAreOnlySizeCompared() throws Exception { + DataTree left = new DataTree(); + support.addNode(left, "/equal", "aa".getBytes(StandardCharsets.UTF_8)); + DataTree right = new DataTree(); + support.addNode(right, "/equal", "bb".getBytes(StandardCharsets.UTF_8)); + + String output = compare(left, right, "0", "0", "-d"); + + assertThat(output, containsString("Filtered node /equal of left size 2, right size 2")); + assertThat(output, not(containsString("Node /equal found"))); + assertThat(output, containsString("All layers compared.")); + } + + @Test + public void testMixedCompressionFormats() throws Exception { + DataTree leftTree = new DataTree(); + support.addNode(leftTree, "/node", new byte[1]); + DataTree rightTree = new DataTree(); + support.addNode(rightTree, "/node", new byte[3]); + File left = support.snapshot(leftTree, StreamMode.CHECKED, true); + for (StreamMode mode : StreamMode.values()) { + File right = support.snapshot(rightTree, mode, true); + String output = run("", arguments(left, right, "0", "0")).success(); + assertThat(output, containsString("Node /node found in both trees. Delta: 2 bytes, 0 descendants")); + } + } + + @Test + public void testSnapshotsWithoutDigest() throws Exception { + DataTree rightTree = new DataTree(); + support.addNode(rightTree, "/node", new byte[3]); + File left = support.snapshot(new DataTree(), StreamMode.CHECKED, false); + File right = support.snapshot(rightTree, StreamMode.CHECKED, false); + + String output = run("", arguments(left, right, "0", "0")).success(); + + assertThat(output, containsString("Node /node found only in right tree. Descendant size: 3. Descendant count: 0")); + } + + @Test + public void testInteractivePathsAndDepths() throws Exception { + DataTree leftTree = new DataTree(); + support.addNode(leftTree, "/app", null); + support.addNode(leftTree, "/app/leaf", new byte[1]); + DataTree rightTree = new DataTree(); + support.addNode(rightTree, "/app", null); + support.addNode(rightTree, "/app/leaf", new byte[3]); + File left = support.snapshot(leftTree, StreamMode.CHECKED, true); + File right = support.snapshot(rightTree, StreamMode.CHECKED, true); + + String output = run("/\n/app\n/app/leaf\n/missing\n-1\n999\nbad\n1\n\n\n", + arguments(left, right, "0", "0", "--interactive")).success(); + + assertThat(output, containsString("Analysis for node /")); + assertThat(output, not(containsString("Path / is neither found"))); + assertThat(output, containsString("Node /app found in both trees. Delta: 2 bytes, 0 descendants")); + assertThat(output, containsString("Node /app/leaf found in both trees. Delta: 2 bytes, 0 descendants")); + assertThat(output, not(containsString("Path /app/leaf is neither found"))); + assertThat(output, containsString("Path /missing is neither found in left tree nor right tree.")); + assertThat(output, containsString("Depth must be in range [0, 2]")); + assertThat(output, containsString("Input bad is not valid.")); + assertThat(output, containsString("Analysis for depth 1")); + assertThat(output, containsString("All layers compared.")); + } + + @Test + public void testInteractiveEndOfInput() throws Exception { + File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, true); + + String output = run("", arguments(snapshot, snapshot, "0", "0", "-i")).success(); + + assertThat(output, containsString("End of input.")); + assertThat(output, not(containsString("All layers compared."))); + } + + @Test + public void testInvalidArguments() throws Exception { + File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, true); + run("").invalidInvocation(); + run("", "-l", snapshot.toString()).invalidInvocation(); + for (String invalid : Arrays.asList("-1", "NaN", "2147483648")) { + run("", arguments(snapshot, snapshot, invalid, "0")).invalidInvocation(); + run("", arguments(snapshot, snapshot, "0", invalid)).invalidInvocation(); + } + run("", arguments(snapshot, snapshot, "0", "0", "--unknown")).invalidInvocation(); + run("", arguments(snapshot, snapshot, "0", "0", "extra")).invalidInvocation(); + } + + @Test + public void testInvalidFiles() throws Exception { + File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, true); + File missing = new File(support.directory, "missing"); + for (File invalid : Arrays.asList(missing, support.directory)) { + run("", arguments(invalid, snapshot, "0", "0")).invalidInvocation(); + run("", arguments(snapshot, invalid, "0", "0")).invalidInvocation(); + } + } + + @Test + public void testCorruptSnapshots() throws Exception { + File valid = support.snapshot(new DataTree(), StreamMode.CHECKED, true); + for (File corrupt : support.corruptSnapshots()) { + SnapshotToolTestSupport.Result result = run("", arguments(corrupt, valid, "0", "0")); + result.readFailure(); + assertThat(result.output, not(containsString("All layers compared."))); + } + } + + @Test + public void testShellLauncher() throws Exception { + Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows")); + DataTree rightTree = new DataTree(); + support.addNode(rightTree, "/node", new byte[3]); + File left = support.snapshot(new DataTree(), StreamMode.CHECKED, true); + File right = support.snapshot(rightTree, StreamMode.GZIP, true); + for (String flags : Arrays.asList("", "-Xms32m -Xmx128m")) { + String output = support.runLauncher("zkSnapshotComparer.sh", flags, + "-l", left.toString(), "-r", right.toString(), "-b", "0", "-n", "0").success(); + assertThat(output, containsString("Node /node found only in right tree. Descendant size: 3. Descendant count: 0")); + } + } + + private String compare(DataTree left, DataTree right, String bytes, String nodes, String... flags) throws Exception { + return run("", arguments(support.snapshot(left, StreamMode.CHECKED, true), + support.snapshot(right, StreamMode.CHECKED, true), + bytes, nodes, flags)).success(); + } + + private String[] arguments(File left, File right, String bytes, String nodes, String... flags) { + List args = new ArrayList<>(Arrays.asList("--left", left.toString(), "--right", right.toString(), + "--bytes", bytes, "--nodes", nodes)); + args.addAll(Arrays.asList(flags)); + return args.toArray(new String[0]); + } + + private SnapshotToolTestSupport.Result run(String input, String... args) throws Exception { + return support.run("org.apache.zookeeper.server.SnapshotComparer", input, args); + } + + static class SnapshotToolTestSupport { + + final File directory = ClientBase.createEmptyTestDir(); + private int nextFile; + + SnapshotToolTestSupport() throws IOException { + } + + void addNode(DataTree tree, String path, byte[] data) throws Exception { + tree.createNode(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, 0, -1, 1, 1); + } + + File snapshot(DataTree tree, StreamMode mode, boolean digest) throws IOException { + File snapshots = new File(directory, "snapshots with spaces"); + assertTrue(snapshots.isDirectory() || snapshots.mkdir()); + File file = new File(snapshots, "snapshot." + Integer.toHexString(++nextFile) + mode.getFileExtension()); + StreamMode previousMode = SnapStream.getStreamMode(); + boolean previousDigest = ZooKeeperServer.isDigestEnabled(); + try { + SnapStream.setStreamMode(mode); + ZooKeeperServer.setDigestEnabled(digest); + new FileSnap(null).serialize(tree, Collections.singletonMap(0x123L, 30000), file, false); + } finally { + SnapStream.setStreamMode(previousMode); + ZooKeeperServer.setDigestEnabled(previousDigest); + } + return file; + } + + List corruptSnapshots() throws IOException { + List files = new ArrayList<>(); + File badHeader = snapshot(new DataTree(), StreamMode.CHECKED, true); + byte[] bytes = Files.readAllBytes(badHeader.toPath()); + bytes[0] ^= 1; + Files.write(badHeader.toPath(), bytes); + files.add(badHeader); + + File truncated = snapshot(new DataTree(), StreamMode.CHECKED, true); + bytes = Files.readAllBytes(truncated.toPath()); + Files.write(truncated.toPath(), Arrays.copyOf(bytes, bytes.length / 2)); + files.add(truncated); + + File checksum = snapshot(new DataTree(), StreamMode.CHECKED, true); + bytes = Files.readAllBytes(checksum.toPath()); + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + new FileSnap(null).deserialize(new DataTree(), new HashMap(), + BinaryInputArchive.getArchive(input)); + int sealOffset = bytes.length - input.available(); + bytes[sealOffset] ^= 1; + Files.write(checksum.toPath(), bytes); + files.add(checksum); + + File partialDigest = snapshot(new DataTree(), StreamMode.CHECKED, true); + bytes = Files.readAllBytes(partialDigest.toPath()); + Files.write(partialDigest.toPath(), Arrays.copyOf(bytes, sealOffset + 14)); + files.add(partialDigest); + + File digestSeal = snapshot(new DataTree(), StreamMode.CHECKED, true); + bytes = Files.readAllBytes(digestSeal.toPath()); + bytes[bytes.length - 13] ^= 1; + Files.write(digestSeal.toPath(), bytes); + files.add(digestSeal); + + File gzipTrailer = snapshot(new DataTree(), StreamMode.GZIP, true); + bytes = Files.readAllBytes(gzipTrailer.toPath()); + bytes[bytes.length - 1] ^= 1; + Files.write(gzipTrailer.toPath(), bytes); + files.add(gzipTrailer); + return files; + } + + Result run(String mainClass, String input, String... args) throws Exception { + List command = new ArrayList<>(Arrays.asList( + new File(System.getProperty("java.home"), "bin/java").toString(), + "-Xmx128m", + "-Djava.io.tmpdir=" + System.getProperty("java.io.tmpdir"), + "-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + mainClass)); + command.addAll(Arrays.asList(args)); + return runProcess(new ProcessBuilder(command), input); + } + + Result runLauncher(String launcher, String flags, String... args) throws Exception { + File script = new File(System.getProperty("basedir", "."), "../bin/" + launcher); + List command = new ArrayList<>(Arrays.asList("bash", script.getCanonicalPath())); + command.addAll(Arrays.asList(args)); + ProcessBuilder builder = new ProcessBuilder(command); + builder.environment().put("JAVA_HOME", System.getProperty("java.home")); + builder.environment().put("CLASSPATH", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"))); + builder.environment().put("JVMFLAGS", flags); + return runProcess(builder, ""); + } + + private Result runProcess(ProcessBuilder builder, String input) throws Exception { + File outputFile = new File(directory, "output-" + ++nextFile); + File inputFile = new File(directory, "input-" + nextFile); + Files.write(inputFile.toPath(), input.getBytes(StandardCharsets.UTF_8)); + Process process = builder + .redirectInput(inputFile) + .redirectErrorStream(true) + .redirectOutput(outputFile) + .start(); + try { + assertTrue("CLI did not exit: " + builder.command(), process.waitFor(30, TimeUnit.SECONDS)); + return new Result(process.exitValue(), + new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8)); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + } + } + } + + void close() throws IOException { + FileUtils.deleteDirectory(directory); + } + + static class Result { + + final int exitCode; + final String output; + + Result(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + + String success() { + assertEquals(output, ExitCode.EXECUTION_FINISHED.getValue(), exitCode); + return output; + } + + void invalidInvocation() { + assertEquals(output, ExitCode.INVALID_INVOCATION.getValue(), exitCode); + assertThat(output.toLowerCase(), containsString("usage:")); + } + + void readFailure() { + assertEquals(output, ExitCode.UNEXPECTED_ERROR.getValue(), exitCode); + assertThat(output, containsString("Unable to read snapshot")); + } + } + } + +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java new file mode 100644 index 00000000000..2e326ee1fc6 --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zookeeper.server; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertThat; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.server.SnapshotComparerTest.SnapshotToolTestSupport; +import org.apache.zookeeper.server.persistence.SnapStream.StreamMode; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +public class SnapshotRecursiveSummaryTest extends ZKTestCase { + + private SnapshotToolTestSupport support; + + @Before + public void setUp() throws IOException { + support = new SnapshotToolTestSupport(); + } + + @After + public void tearDown() throws IOException { + support.close(); + } + + @Test + public void testRecursiveSubtreeTotals() throws Exception { + String output = summarize(tree(), "/app", "0"); + + assertThat(output, containsString(" /app\n children: 5\n data: 17")); + assertThat(output, containsString("-- /app/branch\n-- children: 2\n-- data: 8")); + assertThat(output, containsString("---- /app/branch/deep\n---- children: 1\n---- data: 5")); + assertThat(output, not(containsString(" /app/leaf\n"))); + assertThat(output, not(containsString(" /app/empty\n"))); + assertThat(output, not(containsString(" /zookeeper\n"))); + } + + @Test + public void testDepthOnlyLimitsOutput() throws Exception { + String output = summarize(tree(), "/app", "1"); + + assertThat(output, containsString(" /app\n children: 5\n data: 17")); + assertThat(output, containsString("-- /app/branch\n-- children: 2\n-- data: 8")); + assertThat(output, not(containsString(" /app/branch/deep\n"))); + } + + @Test + public void testRootIncludesItsOwnData() throws Exception { + DataTree tree = tree(); + tree.setData("/", new byte[11], 1, 1, 1); + + String output = summarize(tree, "/", "1"); + + assertThat(output, containsString(" /\n children: 9\n data: 28")); + assertThat(output, containsString("-- /app\n-- children: 5\n-- data: 17")); + assertThat(output, containsString("-- /zookeeper\n-- children: 2\n-- data: 0")); + } + + @Test + public void testSelectedLeafIsNotPrinted() throws Exception { + String output = summarize(tree(), "/app/leaf", "0"); + + assertThat(output, not(containsString("children:"))); + assertThat(output, not(containsString("data:"))); + } + + @Test + public void testNullDataContributesZeroBytes() throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/app", null); + support.addNode(tree, "/app/leaf", null); + + String output = summarize(tree, "/app", "0"); + + assertThat(output, containsString(" /app\n children: 1\n data: 0")); + } + + @Test + public void testEphemeralDescendantsAreIncluded() throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/app", new byte[2]); + tree.createNode("/app/ephemeral", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1); + + String output = summarize(tree, "/app", "0"); + + assertThat(output, containsString(" /app\n children: 1\n data: 9")); + } + + @Test + public void testCompressedSnapshots() throws Exception { + for (StreamMode mode : StreamMode.values()) { + File snapshot = support.snapshot(tree(), mode, true); + String output = run(snapshot.toString(), "/app", "0").success(); + assertThat(output, containsString(" /app\n children: 5\n data: 17")); + } + } + + @Test + public void testSnapshotWithoutDigest() throws Exception { + File snapshot = support.snapshot(tree(), StreamMode.CHECKED, false); + + String output = run(snapshot.toString(), "/app", "0").success(); + + assertThat(output, containsString(" /app\n children: 5\n data: 17")); + } + + @Test + public void testInvalidArguments() throws Exception { + File snapshot = support.snapshot(tree(), StreamMode.CHECKED, true); + run().invalidInvocation(); + run(snapshot.toString(), "/app").invalidInvocation(); + run(snapshot.toString(), "/app", "0", "extra").invalidInvocation(); + for (String invalid : Arrays.asList("-1", "NaN", "2147483648")) { + run(snapshot.toString(), "/app", invalid).invalidInvocation(); + } + } + + @Test + public void testInvalidZnodePaths() throws Exception { + File snapshot = support.snapshot(tree(), StreamMode.CHECKED, true); + for (String invalid : Arrays.asList("", "app", "/app/", "/app//leaf", "/app/..", "/missing")) { + SnapshotToolTestSupport.Result result = run(snapshot.toString(), invalid, "0"); + result.invalidInvocation(); + assertThat(result.output, not(containsString("NullPointerException"))); + } + } + + @Test + public void testInvalidFiles() throws Exception { + run(new File(support.directory, "missing").toString(), "/", "0").invalidInvocation(); + run(support.directory.toString(), "/", "0").invalidInvocation(); + } + + @Test + public void testCorruptSnapshots() throws Exception { + for (File corrupt : support.corruptSnapshots()) { + SnapshotToolTestSupport.Result result = run(corrupt.toString(), "/", "0"); + result.readFailure(); + assertThat(result.output, not(containsString("children:"))); + } + } + + @Test + public void testShellLauncher() throws Exception { + Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows")); + File snapshot = support.snapshot(tree(), StreamMode.GZIP, true); + for (String flags : Arrays.asList("", "-Xms32m -Xmx128m")) { + String output = support.runLauncher("zkSnapshotRecursiveSummaryToolkit.sh", flags, + snapshot.toString(), "/app", "0").success(); + assertThat(output, containsString(" /app\n children: 5\n data: 17")); + } + } + + private DataTree tree() throws Exception { + DataTree tree = new DataTree(); + support.addNode(tree, "/app", new byte[2]); + support.addNode(tree, "/app/branch", new byte[3]); + support.addNode(tree, "/app/branch/deep", null); + support.addNode(tree, "/app/branch/deep/leaf", new byte[5]); + support.addNode(tree, "/app/empty", null); + support.addNode(tree, "/app/leaf", new byte[7]); + return tree; + } + + private String summarize(DataTree tree, String path, String depth) throws Exception { + File snapshot = support.snapshot(tree, StreamMode.CHECKED, true); + return run(snapshot.toString(), path, depth).success(); + } + + private SnapshotToolTestSupport.Result run(String... args) throws Exception { + return support.run("org.apache.zookeeper.server.SnapshotRecursiveSummary", "", args); + } + +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/admin/CommandsTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/admin/CommandsTest.java index eb8c300f61a..11e273d159f 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/admin/CommandsTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/admin/CommandsTest.java @@ -20,16 +20,31 @@ import static org.hamcrest.core.Is.is; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.security.Permission; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import org.apache.zookeeper.CreateMode; +import org.apache.zookeeper.Quotas; +import org.apache.zookeeper.ZooDefs.Ids; +import org.apache.zookeeper.ZooKeeper; +import org.apache.zookeeper.cli.SetQuotaCommand; import org.apache.zookeeper.metrics.MetricsUtils; import org.apache.zookeeper.server.ServerCnxnFactory; import org.apache.zookeeper.server.ServerStats; @@ -37,10 +52,30 @@ import org.apache.zookeeper.server.ZooKeeperServer; import org.apache.zookeeper.server.quorum.BufferStats; import org.apache.zookeeper.test.ClientBase; +import org.junit.After; +import org.junit.Before; import org.junit.Test; public class CommandsTest extends ClientBase { + private static final String QUOTA_ALLOWLIST = "zookeeper.quotaStats.allowedNamespaces"; + private String originalQuotaAllowlist; + + @Before + public void saveQuotaAllowlist() { + originalQuotaAllowlist = System.getProperty(QUOTA_ALLOWLIST); + System.clearProperty(QUOTA_ALLOWLIST); + } + + @After + public void restoreQuotaAllowlist() { + if (originalQuotaAllowlist == null) { + System.clearProperty(QUOTA_ALLOWLIST); + } else { + System.setProperty(QUOTA_ALLOWLIST, originalQuotaAllowlist); + } + } + /** * Checks that running a given Command returns the expected Map. Asserts * that all specified keys are present with values of the specified types @@ -259,6 +294,273 @@ public void testVotingViewCommand() throws IOException, InterruptedException { new Field("current_config", Map.class)); } + @Test + public void testQuotaStatsExactMetadata() throws Exception { + createQuotaFixture(); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + + CommandResponse response = quotaStats("/quota-test"); + assertQuotaResponse(response, "/quota-test", 2, 7L, 10, 100L, null); + StringWriter output = new StringWriter(); + new JsonOutputter().output(response, new PrintWriter(output)); + JsonNode json = new ObjectMapper().readTree(output.toString()); + assertEquals(1, json.get("schema_version").intValue()); + assertEquals(2, json.get("count_used").intValue()); + assertEquals(7L, json.get("bytes_used").longValue()); + assertTrue(json.get("available").booleanValue()); + assertTrue(json.get("reason").isNull()); + assertTrue(json.get("error").isNull()); + } + + @Test + public void testQuotaStatsUnlimitedAndZeroLimits() throws Exception { + ZooKeeper zk = createQuotaFixture(); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + String[] limits = { + "count=-1,bytes=100", "count=10,bytes=-1", "count=-1,bytes=-1", "count=0,bytes=0" + }; + Integer[] counts = {null, 10, null, 0}; + Long[] bytes = {100L, null, null, 0L}; + for (int i = 0; i < limits.length; i++) { + zk.setData(Quotas.quotaPath("/quota-test"), limits[i].getBytes(StandardCharsets.UTF_8), -1); + CommandResponse response = quotaStats("/quota-test"); + assertQuotaResponse(response, "/quota-test", 2, 7L, counts[i], bytes[i], null); + StringWriter output = new StringWriter(); + new JsonOutputter().output(response, new PrintWriter(output)); + JsonNode json = new ObjectMapper().readTree(output.toString()); + assertEquals(counts[i] == null, json.get("count_limit").isNull()); + assertEquals(bytes[i] == null, json.get("bytes_limit").isNull()); + } + } + + @Test + public void testQuotaStatsMissingMetadata() throws Exception { + ZooKeeper zk = createClient(); + zk.create("/quota-test", new byte[0], Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", null, null, null, null, "quota_missing"); + } + + @Test + public void testQuotaStatsMissingNamespace() { + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", null, null, null, null, "namespace_missing"); + } + + @Test + public void testQuotaStatsDoesNotUseAncestorQuota() throws Exception { + createQuotaFixture(); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test/child\"]"); + assertQuotaResponse(quotaStats("/quota-test/child"), "/quota-test/child", + null, null, null, null, "quota_missing"); + } + + @Test + public void testQuotaStatsDefaultAndEmptyAllowlist() { + assertQuotaError(quotaStats("/quota-test"), "not allowlisted"); + System.setProperty(QUOTA_ALLOWLIST, "[]"); + assertQuotaError(quotaStats("/quota-test"), "not allowlisted"); + } + + @Test + public void testQuotaStatsExactAllowlist() { + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + for (String path : Arrays.asList("/quota-test/child", "/quota-test-other", "/Quota-test")) { + assertQuotaError(quotaStats(path), "not allowlisted"); + } + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", null, null, null, null, "namespace_missing"); + + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test/*\"]"); + assertQuotaError(quotaStats("/quota-test/child"), "not allowlisted"); + assertQuotaResponse(quotaStats("/quota-test/*"), "/quota-test/*", + null, null, null, null, "namespace_missing"); + } + + @Test + public void testQuotaStatsRejectsMalformedAllowlist() { + String[] configurations = { + "", "null", "{}", "\"/quota-test\"", "[1]", "[null]", "[true]", "[[]]", + "[\"/quota-test\"", "[\"/quota-test\",]", "[\"/quota-test\"] []", + "[\"/quota-test\", \"sensitive-token\"]", "[\"/quota-test\", \"/\"]", + "[\"/quota-test\", \"/zookeeper/quota\"]", "[\"/quota-test\", \"/other//child\"]", + "['/quota-test']", "/* comment */[\"/quota-test\"]" + }; + for (String configuration : configurations) { + System.setProperty(QUOTA_ALLOWLIST, configuration); + CommandResponse response = quotaStats("/quota-test"); + assertQuotaError(response, QUOTA_ALLOWLIST); + assertFalse(response.getError().contains("sensitive-token")); + } + } + + @Test + public void testQuotaStatsRejectsMissingOrInvalidPaths() { + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + assertQuotaError(Commands.runCommand("quota_stats", serverFactory.getZooKeeperServer(), null), "path"); + assertQuotaError(Commands.runCommand("quota_stats", serverFactory.getZooKeeperServer(), + Collections.emptyMap()), "path"); + String[] paths = { + null, "", "quota-test", "/", "/zookeeper", "/zookeeper/config", "/zookeeper/quota", + "/quota-test/", "/quota-test//child", "/quota-test/.", "/quota-test/../child", + "/quota-test/\u0000", "/quota-test/\n", "/quota-test/\ud800", "/quota-test/\uffff" + }; + for (String path : paths) { + assertQuotaError(quotaStats(path), "path"); + } + } + + @Test + public void testQuotaStatsValidPathComponents() throws Exception { + String[] paths = {"/zookeeper-client", "/quota-test/child", "/quota-test/..child", "/quota-test/\u00e9"}; + System.setProperty(QUOTA_ALLOWLIST, new ObjectMapper().writeValueAsString(paths)); + for (String path : paths) { + assertQuotaResponse(quotaStats(path), path, null, null, null, null, "namespace_missing"); + } + System.setProperty(QUOTA_ALLOWLIST, " [ \"/quota-test\", \"/quota-test\" ] "); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", null, null, null, null, "namespace_missing"); + } + + @Test + public void testQuotaStatsReflectsAllowlistChanges() { + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", null, null, null, null, "namespace_missing"); + System.clearProperty(QUOTA_ALLOWLIST); + assertQuotaError(quotaStats("/quota-test"), "not allowlisted"); + System.setProperty(QUOTA_ALLOWLIST, "invalid"); + assertQuotaError(quotaStats("/quota-test"), QUOTA_ALLOWLIST); + } + + @Test + public void testQuotaStatsUninitializedServer() { + String expected = Commands.runCommand("ruok", null, null).getError(); + for (ZooKeeperServer server : Arrays.asList(null, new ZooKeeperServer())) { + CommandResponse response = Commands.runCommand("quota_stats", server, + Collections.singletonMap("path", "/quota-test")); + assertQuotaError(response, expected); + } + } + + @Test + public void testQuotaStatsMalformedMetadataReturnsUnavailable() throws Exception { + ZooKeeper zk = createQuotaFixture(); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + zk.setData(Quotas.statPath("/quota-test"), "sensitive-token".getBytes(StandardCharsets.UTF_8), -1); + CommandResponse response; + try { + response = quotaStats("/quota-test"); + } catch (RuntimeException e) { + throw new AssertionError("Quota telemetry must report unavailable, not throw " + e.getClass().getSimpleName()); + } + assertQuotaResponse(response, "/quota-test", null, null, null, null, "invalid_quota_stats"); + StringWriter output = new StringWriter(); + new JsonOutputter().output(response, new PrintWriter(output)); + assertFalse(output.toString().contains("sensitive-token")); + JsonNode json = new ObjectMapper().readTree(output.toString()); + assertTrue(json.get("count_used").isNull()); + assertTrue(json.get("bytes_used").isNull()); + assertTrue(json.get("count_limit").isNull()); + assertTrue(json.get("bytes_limit").isNull()); + assertFalse(json.get("available").booleanValue()); + } + + @Test + public void testQuotaStatsIncompleteMetadataReturnsUnavailable() throws Exception { + ZooKeeper zk = createQuotaFixture(); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + zk.delete(Quotas.statPath("/quota-test"), -1); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", null, null, null, null, "quota_incomplete"); + } + + @Test + public void testQuotaStatsDeniedConfigurationAccess() { + SecurityManager original = System.getSecurityManager(); + try { + System.setSecurityManager(new SecurityManager() { + @Override + public void checkPermission(Permission permission) { + } + + @Override + public void checkPropertyAccess(String key) { + if (QUOTA_ALLOWLIST.equals(key)) { + throw new SecurityException("sensitive-token"); + } + } + }); + CommandResponse response = quotaStats("/quota-test"); + assertQuotaError(response, QUOTA_ALLOWLIST); + assertFalse(response.getError().contains("sensitive-token")); + } finally { + System.setSecurityManager(original); + } + } + + @Test + public void testQuotaStatsValidationPrecedesTreeAccess() { + ZooKeeperServer server = mock(ZooKeeperServer.class); + when(server.isRunning()).thenReturn(true); + when(server.getZKDatabase()).thenThrow(new AssertionError("Rejected requests must not access the data tree")); + assertQuotaError(Commands.runCommand("quota_stats", server, null), "path"); + Map request = Collections.singletonMap("path", "/quota-test"); + assertQuotaError(Commands.runCommand("quota_stats", server, request), "not allowlisted"); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\", null]"); + assertQuotaError(Commands.runCommand("quota_stats", server, request), QUOTA_ALLOWLIST); + } + + @Test + public void testQuotaStatsAfterServerRestart() throws Exception { + createQuotaFixture(); + System.setProperty(QUOTA_ALLOWLIST, "[\"/quota-test\"]"); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", 2, 7L, 10, 100L, null); + stopServer(); + startServer(); + stopServer(); + startServer(); + assertQuotaResponse(quotaStats("/quota-test"), "/quota-test", 2, 7L, 10, 100L, null); + System.clearProperty(QUOTA_ALLOWLIST); + assertQuotaError(quotaStats("/quota-test"), "not allowlisted"); + } + + private ZooKeeper createQuotaFixture() throws Exception { + ZooKeeper zk = createClient(); + zk.create("/quota-test", "abc".getBytes(StandardCharsets.UTF_8), Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + SetQuotaCommand.createQuota(zk, "/quota-test", 100L, 10); + zk.create("/quota-test/child", new byte[4], Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + return zk; + } + + private CommandResponse quotaStats(String path) { + return Commands.runCommand("quota_stats", serverFactory.getZooKeeperServer(), + Collections.singletonMap("path", path)); + } + + private void assertQuotaError(CommandResponse response, String message) { + assertEquals("quota_stats", response.getCommand()); + assertNotNull(response.getError()); + assertTrue(response.getError(), response.getError().contains(message)); + Map result = response.toMap(); + assertEquals(response.getError(), result.get("error")); + assertEquals(new HashSet<>(Arrays.asList("command", "error")), result.keySet()); + } + + private void assertQuotaResponse(CommandResponse response, String path, Integer countUsed, Long bytesUsed, + Integer countLimit, Long bytesLimit, String reason) { + assertEquals("quota_stats", response.getCommand()); + assertNull(response.getError(), response.getError()); + Map result = response.toMap(); + assertEquals(new HashSet<>(Arrays.asList("command", "error", "schema_version", "path", + "count_used", "bytes_used", "count_limit", "bytes_limit", + "available", "reason")), result.keySet()); + assertEquals(1, result.get("schema_version")); + assertEquals(path, result.get("path")); + assertEquals(countUsed, result.get("count_used")); + assertEquals(bytesUsed, result.get("bytes_used")); + assertEquals(countLimit, result.get("count_limit")); + assertEquals(bytesLimit, result.get("bytes_limit")); + assertEquals(reason == null, result.get("available")); + assertEquals(reason, result.get("reason")); + } + @Test public void testConsCommandSecureOnly() { // Arrange