Skip to content

Bug fixes, structured logging, config validation and CI - #8

Merged
ayebrian merged 19 commits into
mainfrom
claude/code-review-bugs-tknawl
Aug 13, 2026
Merged

Bug fixes, structured logging, config validation and CI#8
ayebrian merged 19 commits into
mainfrom
claude/code-review-bugs-tknawl

Conversation

@ayebrian

@ayebrian ayebrian commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Targets main directly so the whole main → dev → feat/zrle chain lands as one PR instead of a stack. Everything on dev and feat/zrle is already contained here (verified with git merge-base --is-ancestor), so both branches become redundant once this merges.

The first six commits are the pre-existing dev + feat/zrle work — the 2.0 config format, image rotation and ZRLE encoding. Everything below is new on top of that.

CI is green on all five checks, and --check, log rotation, the banner and each handshake rejection path were also exercised against a running server.

Bugs fixed

The release pipeline was broken end to end

  • install.sh ran go build -o fictusvnc main.go, but the package spans eight files — it failed with undefined: appVersion and seven more. It now builds the package. FLAGS=(-ldflags=-s -w) was also expanded as $FLAGS, silently dropping -w.
  • The release workflow pinned Go 1.24 while go.mod required 1.25.0, so every tagged build failed. It also copied a config.toml that is not in the repo, with the failure swallowed by || true, shipping archives without a config — and the binary exits 1 on first run without one. It now packages config.example.toml.
  • Tagged releases embedded whatever version was hardcoded in the source rather than the tag.

Resource exhaustion

  • No write deadline was ever set. A client that stopped reading (TCP zero-window) pinned a goroutine forever, holding a full per-connection framebuffer copy. Every send is now bounded.
  • Incremental FramebufferUpdateRequests were answered with a full frame. Real clients re-request as soon as each update lands, so this spun at 100% CPU and saturated the link redrawing an image that never changes. The first update is still unconditional, so a client never faces a blank screen; later incremental ones are ignored, as a real server does.
  • ClientCutText trusted a uint32 length. int(n) goes negative on the 386 targets build.sh produces, making io.CopyN return immediately and desync the stream. Capped at 1 MiB.
  • No connection limit existed. New max_connections (default 512, 0 disables) is process-wide, since the memory it protects is shared across listeners.
  • The banner was built on connect — a framebuffer copy (~8 MB at 1080p) plus, with rDNS, a DNS query — before a single protocol byte was exchanged. At the default cap that was ~4 GB worst case for clients that might never request a frame. It is now built on the first update request, so scanners that connect and vanish cost neither.

Correctness

  • The client IP overlay split RemoteAddr on ":", yielding [ for an IPv6 peer.
  • Image paths resolved against the working directory while the config path defaulted to the executable's directory, so a service started outside its install dir found its config but not its images. Images now resolve against the config file's directory.
  • With no server able to start, select {} left the process alive and silent forever — systemd saw a healthy service listening on nothing. Listeners bind synchronously, a zero-listener start exits non-zero, and SIGINT/SIGTERM shut down cleanly.
  • getSequential did a non-atomic load-then-increment, handing the same image to concurrent connections.
  • An unknown message type drained one byte and kept parsing a stream it could no longer interpret.
  • The client's RFB version and chosen security type were read and discarded. A malformed greeting is now refused instead of being parsed as protocol, RFB 3.3 is refused explicitly (it puts the server in charge of the security type over a different flow), and a client selecting an unoffered type gets a proper RFB 3.8 failure result.

Observability

Structured logging on log/slog — stdlib, no new dependency. A connection used to produce six unrelated lines behind an unparseable [Acme - Reception] prefix; it now produces one event carrying the whole session:

{"time":"...","level":"INFO","msg":"connection","server":"Reception","listen":"0.0.0.0:5900",
 "peer_ip":"198.51.100.42","peer_port":48280,"handshake":true,"outcome":"client_eof",
 "duration_ms":1049,"bytes_sent":6407,"client_version":"RFB 003.008","security_type":1,
 "image":"default.png","updates":1,"encodings":[16,0,-239],"encoding_used":"zrle"}

Three of those fields were already being read and thrown away. The encoding list in the client's own order is the best available fingerprint of which VNC software is on the other end — the whole point of running a honeypot. outcome is a small stable vocabulary so it groups cleanly, and handshake separates real clients from probes that open a socket and vanish.

New [logging] section: level, format (json/text) and output (stdout, stderr or a file path). Shipping to Elasticsearch or Loki needs no code here — JSON on stdout is what every collector consumes. A file sink is reopened on SIGHUP so logrotate works.

Config

Renamed for clarity; the 2.0 spellings still load and emit a deprecation notice:

Old New Note
show_ip show_client_ip same meaning
no_brand branding inverted, defaults to true
server_name name matches the global key

The global name is now actually used as the branding prefix — it was previously unreachable, because the per-server fallback to the section id always won and section ids are never empty.

Banner flags are per-server. show_client_ip, show_rdns and show_time are global defaults that any server can override. Server fields are *bool so three states are distinguishable: inherit, force on, force off — a plain bool cannot express "switch a globally enabled line back off".

Typos are reported. A mistyped key used to be dropped in silence, so the option simply looked broken; a mistyped section name produced a misleading "check listen addresses and image paths". Both are caught now, at every nesting level, including keys inside inline image tables. Settings that silently override one another are flagged too — image with images, a port in listen with a port range, an unrecognised rotation_mode.

--check parses the config, loads every image and prints a summary without binding a port, so it is safe to run on a live host. It exits non-zero for anything that would stop a server starting, including two servers claiming the same address — which previously only surfaced as a bind failure at startup.

Banner rendering

Was a hardcoded 360×22 box in a fixed 13px bitmap face: unreadable on a wallpaper-sized image, and either clipping the text or leaving a wide empty bar. It is now measured from its contents — Go Mono at a size proportional to the image, a box sized to the widest line plus proportional padding, shrinking until it fits the image width, so a full IPv6 address or a long hostname no longer runs off the edge. Optional rDNS and timestamp lines were added. Go Mono and opentype ship inside golang.org/x/image, so this adds no module.

Build and release

  • CI (ci.yml, new): gofmt, go vet, go test -race, a go mod tidy diff, and the full build.sh matrix on every PR and push to main. None of this ran before — the only workflow triggered on v* tags, so nothing was checked until release time.
  • Rolling dev pre-releases (dev-release.yml, new): every push to main publishes the full platform matrix under a moving dev tag, versioned 2.1.0-dev.g<sha>. The tag moves rather than accumulating one release per commit.
  • Packaging moved out of release.yml into a shared package.sh instead of being duplicated between workflows.
  • Go 1.26.5; golang.org/x/image 0.41 → 0.44; actions/checkout and setup-go v4 → v7; action-gh-release v2 → v3.

Tests

57 tests, all with -race. Beyond the ZRLE round-trip that already existed: config loading, key deprecation, typo detection, per-server banner inheritance, --check verdicts, listen-address expansion, banner geometry, incremental-request suppression, cut-text limits, unknown messages, every handshake rejection path, IPv6 peer parsing, sequential rotation under concurrency, log rotation across a rename, and connection-record contents.

Several were checked against the pre-fix code and fail there — the rotation race (62–63 distinct images out of 64), the three handshake rejections, and the lazy-overlay test.

Still open

Reported but deliberately not fixed here: the 24bpp colour converter ignores RShift/GShift/BShift and endianness, the 32bpp path masks rather than scales when RMax < 255, there is no /metrics endpoint, and there is no buffered writer.

ayebrian and others added 19 commits September 2, 2025 11:42
…ion support, and update version to 2.0.0; improve README and example configuration
ZRLE (encoding 16):
- Per-connection continuous zlib stream (zrle.go) with Z_SYNC_FLUSH
  after each framebuffer update, as the RFB spec requires.
- 64x64 tiling with solid (subencoding 1) and raw (subencoding 0)
  tiles; flat desktop areas collapse to a single CPIXEL.
- 3-byte CPIXEL packing for 32bpp true-colour formats whose colour
  bits fit in the low 3 bytes; falls back to Raw otherwise.
- Client encoding list is now parsed in SetEncodings to detect ZRLE
  support; FramebufferUpdateRequest picks ZRLE when negotiated.
- Round-trip test decodes the zlib/tile stream and verifies pixels,
  including partial edge tiles and both subencodings.

Fixes:
- Sequential rotation no longer skips the first image: startup logging
  peeked via GetImage(), which advanced the sequential counter.
- KeyEvent/PointerEvent/ClientCutText are now drained with their
  correct lengths instead of a blind 255-byte read that desynced the
  protocol stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the ZRLE tile encoder beyond solid/raw with the full set of
subencodings: packed palette (2-16 colours, 1/2/4 bits per index),
palette RLE (130-255) and plain RLE (128). Each tile picks the
cheapest option by pre-zlib byte cost, the heuristic real encoders use.

The round-trip test now decodes every subencoding and the test image
is structured to force each path. A bandwidth test measures real
images: on a busy desktop screenshot palette/RLE shaves ~9% off the
solid+raw stream, ~23% on simpler images, and the whole ZRLE path
stays at a few percent of the original uncompressed Raw encoding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- framebuffer: converter writes into a caller buffer instead of
  allocating a 3-4 byte slice per pixel; sendFramebuffer no longer
  copies through a temporary. Removes ~w*h allocations per update.
- addIPOverlay: load the BGRX source into the NRGBA buffer as RGBA
  with opaque alpha and convert back afterwards. Previously the base
  was treated as fully transparent, so the semi-transparent IP banner
  rendered as a solid black bar and channels were only correct by luck.
- main: build port-range listen addresses with net.SplitHostPort/
  JoinHostPort so IPv6 hosts no longer break (strings.Split on ":").
- server: recover() in the per-connection goroutine so malformed
  client input can't take down the whole process.
- Tests: Raw framebuffer round-trip (locks the converter refactor)
  and an IP-overlay test (background darkened, pixels below the banner
  untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove the duplicated Features section and the mojibake (replacement
  characters) left by a bad encoding round-trip.
- Add a ZRLE bullet and an Encodings section explaining ZRLE/Raw
  negotiation and view-only input handling.
- Document build.sh for multi-platform release builds; drop the stale
  clone URL from the build steps. Note the ~3MB binary size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- github.com/BurntSushi/toml v1.5.0 -> v1.6.0
- golang.org/x/image v0.27.0 -> v0.41.0 (resolves Dependabot advisory)
- golang.org/x/text v0.25.0 -> v0.37.0 (indirect)

The go directive moves to 1.25.0 because the updated x/image and
x/text both require it. Verified: go vet clean, all tests pass, and
build.sh cross-compiles all seven release targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug fixes:

- install.sh built only main.go, so the package never compiled
  ("undefined: appVersion" and 7 more). Build the package instead, and
  fix the FLAGS array so -w is not dropped from the release ldflags.
- The release workflow pinned Go 1.24 while go.mod requires 1.25, so
  every tagged build failed. It also copied a config.toml that does not
  exist in the repo, with the error swallowed by "|| true", shipping
  archives without a config. Package config.example.toml as config.toml.
- Connections had no write deadline, so a client that stopped reading
  (TCP zero-window) pinned a goroutine and, with the IP overlay enabled,
  a full framebuffer copy forever. Bound every send.
- Incremental FramebufferUpdateRequests were answered with a full frame.
  Clients re-request as soon as each update lands, so this spun at 100%
  CPU and saturated the link for an image that never changes. Only the
  first update is now unconditional; later incremental requests are
  ignored, as a real server would.
- The client IP overlay split RemoteAddr on ":", yielding "[" for IPv6
  peers. Use net.SplitHostPort.
- Image paths resolved against the working directory while the config
  path defaulted to the executable's directory, so a service started
  outside the install dir found its config but not its images. Resolve
  images against the config file's directory; absolute paths are kept.
- With no server able to start, "select {}" left the process alive and
  silent forever. Listeners are now bound synchronously, a zero-listener
  start exits non-zero, and SIGINT/SIGTERM shut down cleanly.
- getSequential did a non-atomic load-then-increment and handed the same
  image to concurrent connections. Claim and advance in one step.
- ClientCutText trusted a uint32 length: int(n) goes negative on the
  386 targets build.sh produces, desyncing the stream. Cap at 1 MiB.
- An unknown message type drained one byte and kept parsing a stream it
  could no longer interpret. Close the connection instead.

Config keys:

- show_ip     -> show_client_ip
- no_brand    -> branding (inverted, defaults to true)
- server_name -> name (matching the global key)

The 2.0 spellings still load and log a deprecation warning. The global
name is now used as the branding prefix; previously it was unreachable
because the per-server fallback to the section id always won.

Adds tests for config loading and key deprecation, listen address
expansion, incremental-request suppression, cut-text limits, unknown
message handling, IPv6 peer parsing, and sequential rotation under
concurrency (the last one fails against the previous implementation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
- golang.org/x/image v0.41.0 -> v0.44.0
- actions/checkout v4 -> v7
- actions/setup-go v4 -> v7
- softprops/action-gh-release v2 -> v3

github.com/BurntSushi/toml is already at the latest release (v1.6.0).
The Go version pin stays at "1.25", which resolves to the newest 1.25.x
patch and matches the go directive in go.mod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
Bumps the go directive and the CI toolchain pin. The full cross-platform
build.sh matrix (linux/windows/darwin, amd64/arm64/386) and go test -race
both pass on the new toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
setup-go now resolves the newest 1.26.x at build time, so security
patches land without a commit. go.mod keeps go 1.26.5 as the minimum
the module requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
The overlay was a hardcoded 360x22 box with the fixed 7x13 bitmap face:
unreadably small on a wallpaper-sized image, and either cutting the text
off or leaving a wide empty bar depending on the address length.

The banner is now measured from its contents. The font is Go Mono at a
size proportional to the image height (clamped to 11-40px), the box is
the widest line plus a proportional padding, and the size steps down
until the box fits the image width — so a full IPv6 address or a long
hostname shrinks to fit instead of running off the edge. Go Mono and
opentype both ship inside golang.org/x/image, so this adds no module.

The banner takes any number of lines, driven by three global options:

  show_client_ip  the client address (unchanged default: off)
  show_rdns       the client's reverse-DNS name
  show_time       the connection timestamp

show_rdns and show_time default to off. The PTR lookup only runs when it
will be displayed, so the default configuration generates no DNS traffic;
when enabled it is bounded at 700ms and falls back to "(no PTR record)".
README documents the tradeoff: the lookup precedes the handshake and
queries the client's own DNS authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
Replaces 43 log.Printf calls with slog. No new dependency: log/slog is
stdlib, and the binary grows 3.58 -> 3.88 MB.

The important change is what gets logged. A connection used to produce
six unrelated lines prefixed with "[Acme - Reception]", which cannot be
correlated and cannot be parsed. It now produces one event carrying the
whole session:

  peer_ip, peer_port, rdns, client_version, security_type, handshake,
  encodings, encoding_used, pixel_bpp, pixel_depth, image, updates,
  bytes_sent, duration_ms, outcome

Three of those were already being read and thrown away: the client's
version string, the security type it picked, and the full encoding list.
The encoding list in the client's own order is the best fingerprint of
which VNC software is on the other end, which is the whole point of
running a honeypot. Per-message protocol detail moves to debug level, so
info is exactly one record per connection.

outcome is a small stable vocabulary (client_eof, idle_timeout,
unknown_message, version_read_failed, update_write_failed, ...) so it
groups cleanly, and handshake separates real clients from probes that
open a socket and vanish. Bytes are counted by wrapping the connection.

New [logging] section: level, format (json|text) and output (stdout,
stderr or a file path). Shipping to Elasticsearch or Loki needs no code
here — JSON on stdout is what every collector already consumes. A file
sink is reopened on SIGHUP so logrotate works; without it the server
would keep writing to the rotated-away inode.

loadConfig now returns deprecation warnings instead of logging them,
since the logger does not exist until the config has been read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
The only workflow in the repository triggered on v* tags, so nothing was
ever checked before a release: tests, vet and formatting never ran on a
pull request or a push.

Adds a CI workflow on pull_request and pushes to main/dev with two jobs:

  test   gofmt check, go vet, go test -race, and a go mod tidy diff so a
         stale go.sum cannot land
  build  the full build.sh matrix, because a cross-compile break on the
         386 or darwin targets would otherwise only surface at tag time

In-flight runs for a branch are cancelled when it is pushed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
Closes the three items left open by the original review.

Handshake validation. The client's version string and its chosen security
type were both read and then ignored. A greeting that is not a well-formed
"RFB xxx.yyy" is now refused instead of being parsed as though the rest of
the stream were protocol, and RFB 3.3 is refused explicitly: those
revisions have the server choose the security type over a different
message flow, so serving them a 3.7+ negotiation desyncs. A client that
selects a type that was never offered gets a proper RFB 3.8 failure result
— status 1 with a reason — rather than a silent success. Each case has its
own outcome (malformed_version, unsupported_version, bad_security_type),
so misbehaving scanners separate cleanly from real clients in the log.

Connection limit. New global max_connections, default 512, 0 for
unlimited. The limiter is process-wide rather than per listener because
the resource at risk is memory: with the info banner enabled every
connection holds a private framebuffer copy, which for a 1080p image is
about 8 MB. Clients over the cap are closed before any greeting and
recorded with outcome connection_limit. An absent key takes the default
while an explicit 0 survives, so switching the cap off stays possible.

Removes ImageRotator.GetStats, which was never called.

Tests cover version parsing and support boundaries, all three rejection
paths end to end, the limiter semantics including the nil (unlimited)
case, slot release after a client disconnects, and max_connections
defaulting. The three rejection tests were checked against the previous
behaviour and fail there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
The overlay was built as soon as a connection arrived: a full framebuffer
copy (~8 MB at 1080p) plus, with show_rdns, a PTR lookup — all before a
single protocol byte was exchanged. Any TCP connect paid that price, and
under the default max_connections of 512 the worst case was ~4 GB of
overlay copies held by clients that might never request a frame. The
pre-handshake lookup also delayed the RFB greeting, which is itself a
tell for whoever is probing.

The overlay (and its lookup) is now built on the first
FramebufferUpdateRequest and reused for later updates. ServerInit is
answered from the original image, whose dimensions the overlay preserves.
Scanners that connect and vanish — most traffic on an exposed honeypot —
now cost neither the copy nor any DNS traffic, and the greeting is never
delayed. Image selection stays per-connection so sequential rotation
semantics are unchanged.

lookupRDNS becomes a stubable variable so tests can observe when a
resolution actually happens. New tests pin the behaviour: a client that
handshakes without requesting a frame triggers no lookup (fails against
the eager build), and a client that does request one gets a frame with
the banner actually drawn, with exactly one lookup across repeated
updates. README and the example config no longer describe the
pre-handshake lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
Actions were disabled repository-wide when the CI workflow first landed,
so no run was ever created for it. This push both adds a manual trigger
for future re-runs and, as a PR synchronize event, kicks off the first
real run now that Actions are enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
Every push to main now builds the full platform matrix and publishes it
as a pre-release under a rolling "dev" tag, so the latest main build is
always downloadable without cutting a version tag. The tag moves to the
newest commit rather than accumulating one release per push.

Version stamping. appVersion becomes a var so build.sh can inject a
value via -ldflags -X when VERSION is set; a plain build still reports
the baseline. Dev builds report e.g. 2.1.0-dev.gabc1234 (the "g" keeps
the semver pre-release identifier alphanumeric), and tagged releases now
report the tag instead of whatever was hardcoded. The base version is
read from config.go, so it stays the single source of truth.

Packaging moves out of release.yml into package.sh, shared by both the
tagged-release and dev-build workflows instead of being duplicated. The
dev workflow also gets a workflow_dispatch trigger for manual runs, and a
concurrency group so a newer push cancels an in-flight dev build. dist/
is gitignored since package.sh writes there.

build.sh, package.sh and version injection were exercised locally: the
binary reports the injected version, a plain build reports the baseline,
and the archives unpack to binary + config.toml + images/default.png.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
show_client_ip, show_rdns and show_time were global only, so a config
with several servers had to enable the banner everywhere or nowhere.
That is a real limitation for a multi-honeypot setup: you generally want
the banner on the hosts you are watching and nothing on the ones meant
to look untouched.

The three keys are now accepted on a server too, where they override the
[global] default. Server fields are *bool rather than bool so that three
states are distinguishable — inherit, force on, force off. A plain bool
could not express "switch a globally enabled line back off", since its
zero value is indistinguishable from an absent key.

Verified end to end: two servers in one config, one inheriting a global
show_client_ip = true and one overriding it to false, serve frames that
differ in exactly the banner pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
A mistyped key was silently dropped, so the option simply looked broken
with nothing in the log to explain it; a mistyped section name produced a
misleading "check listen addresses and image paths". Both are now caught
through the decoder's Undecoded(), which reports typos at every level:
[global], [logging], a section name, a key inside a server, and a key
inside an inline image table. Deprecated keys are real struct fields, so
they keep producing their own notices rather than being flagged as typos.

Also warns where settings silently override one another — rotation_mode
outside {random, sequential}, image together with images, and a port in
listen together with a port range — since each of those otherwise just
discards what was written.

--check parses the config, loads every image and prints a summary
without binding a port, so it can run against a live host. It exits
non-zero for anything that would stop a server starting: a missing or
corrupt image, no listen address, no [server.*] sections, or two servers
claiming the same address, which previously only surfaced as a bind
failure at startup. Warnings alone do not fail it.

Warnings are emitted in a stable order; they are collected while walking
a map, so server ids are sorted to keep runs reproducible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaJTvGD9ysDpoMxa6kNTL5
@ayebrian ayebrian changed the title ZRLE encoding, image rotation, bug fixes and config key cleanup Bug fixes, structured logging, config validation and CI Aug 5, 2026
@ayebrian
ayebrian merged commit 80cd2ed into main Aug 13, 2026
5 checks passed
@ayebrian
ayebrian deleted the claude/code-review-bugs-tknawl branch August 13, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants