snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
misc/* linguist-vendored

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: centrifugal

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: "\U0001F41BBug report"
about: Create a report to help us improve
title: "[bug] "
labels: ''
assignees: ''
---
**Describe the bug**. A clear and concise description of what the bug is.
...
**Versions**
Centrifugo version is <???>
Client library used is centrifuge-<???> of version <???>
Operating system is <???>
**Steps to Reproduce** How can the bug be triggered?
...
**Expected behavior** What output or behaviour were you expecting instead?
...
**Code Snippets** A minimum viable code snippet can be useful.
```
Your code here.
```

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: 🗨️ GitHub Discussions
url: https://github.com/centrifugal/centrifugo/discussions
about: Ask usage questions, share ideas, and get help from the community. Answers here are searchable and benefit others.
- name: 📨 Telegram community
url: https://t.me/joinchat/ABFVWBE0AhkyyhREoaboXQ
about: Chat with the community — fastest way to get a quick answer.
- name: 🎮 Discord
url: https://discord.gg/tYgADKx
about: Join the Discord server for community chat.
- name: 📖 Documentation & FAQ
url: https://centrifugal.dev/docs/faq
about: Most "how do I…" questions are already answered in the docs and FAQ.

View File

@@ -0,0 +1,17 @@
---
name: "\U0001F4A1Feature proposal"
about: Suggest an idea for this project
title: "[feature] "
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
When I tried to achieve...
**Describe the solution you'd like**.
> What would the feature look like? How would it work? How would it change the API?
...

3
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,3 @@
## Proposed changes
Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue.

17
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: monthly
ignore:
- dependency-name: github.com/centrifugal/centrifuge # updating manually.
- dependency-name: github.com/mailru/easyjson # updating manually.
groups:
go-packages:
patterns:
- '*'
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly

13
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": ["dockerfile"],
"packageRules": [
{
"description": "Alpine base image: only propose stable MAJOR.MINOR release tags (no edge, RC, patch or datestamp tags).",
"matchDatasources": ["docker"],
"matchPackageNames": ["alpine"],
"allowedVersions": "/^\\d+\\.\\d+$/"
}
]
}

65
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: release
on:
push:
tags:
- '*'
jobs:
release:
name: Release with Go ${{ matrix.go-version }}
environment: release
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [1.26.8]
steps:
- name: Install Go
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go-version }}
- uses: bufbuild/buf-setup-action@v1
with:
version: "1.34.0"
- name: Checkout code
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: all
- name: Set up Docker buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build DEB and RPM packages
run: misc/scripts/release_package.sh
env:
STATS_ENDPOINT: ${{ secrets.STATS_ENDPOINT }}
STATS_TOKEN: ${{ secrets.STATS_TOKEN }}
- name: Release on Github
uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --rm-dist --release-notes=misc/release/notes.md
env:
# note custom secret here since we need access to Centrifugo Homebrew repo.
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
STATS_ENDPOINT: ${{ secrets.STATS_ENDPOINT }}
STATS_TOKEN: ${{ secrets.STATS_TOKEN }}
- name: Release on Packagecloud
env:
PACKAGECLOUD_TOKEN: ${{ secrets.PACKAGECLOUD_TOKEN }}
run: misc/scripts/release_packagecloud.sh
- uses: bufbuild/buf-push-action@v1
with:
buf_token: ${{ secrets.BUF_TOKEN }}

49
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: test
on:
push:
tags-ignore:
- '**'
branches:
- '**'
pull_request:
jobs:
golangci:
name: Lint
runs-on: ubuntu-latest
# Prevent duplicate builds on internal PRs.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: stable
- name: Read golangci-lint version
id: golangci-lint-version
run: echo "version=$(cat .golangci-lint-version)" >> "$GITHUB_OUTPUT"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: ${{ steps.golangci-lint-version.outputs.version }}
args: --timeout 10m0s --verbose
test:
name: Test with Go ${{ matrix.go-version }}
runs-on: ubuntu-latest
# Prevent duplicate builds on internal PRs.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
strategy:
matrix:
go-version: [1.26.8]
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install Go
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go-version }}
- name: Start services
run: docker compose up -d --wait
- name: Test
run: make test-integration

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
.DS_Store
centrifugo
/config.yaml
/config.*.yaml
/config.json
/config.*.json
/config.toml
/config.*.toml
BUILDS
PACKAGES
FlameGraph
torch.svg
protoc-3*
.env
/*.env
.venv
.vscode
.idea
docs/site
internal/ultra
misc/clickhouse_cluster/clickhouse*
misc/clickhouse_cluster/zookeeper
misc/redis_instances/redis_data
misc/redis_cluster/cluster_data
misc/redis_sentinel/sentinel_data
misc/nats_cluster/nats_logs
vendor/
*.orig
tmp/
.rocks/

1
.golangci-lint-version Normal file
View File

@@ -0,0 +1 @@
v2.13.1

35
.golangci.yml Normal file
View File

@@ -0,0 +1,35 @@
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- bodyclose
- gosec
settings:
staticcheck:
checks:
- "all"
- "-ST1001" # Excludes dot imports check
- "-ST1003" # Excludes bad variable names check, sth to refactor.
- "-QF1008" # Prefer explicit access to fields.
- "-ST1000" # No need in package comment check.
- "-QF1003" # Tagged switch cases.
- "-QF1012" # Use fmt.Fprintf(...) instead of Write([]byte.
- "-S1000" # should use for range instead of for { select {} }.
- "-S1037" # should use time.Sleep instead of elaborate way of sleeping.
- "-ST1020" # comment on exported method - sth to fix.
- "-ST1016" # stable receivers names - sth to fix.
gosec:
excludes:
- "G115" # Integer overflow check - sth to fix.
exclusions:
rules:
- path: "_test\\.go"
linters:
- bodyclose # Disable in tests, sth to fix.
- gosec # Disable in tests, sth to fix.

129
.goreleaser.yml Normal file
View File

@@ -0,0 +1,129 @@
project_name: centrifugo
release:
github:
owner: centrifugal
name: centrifugo
name_template: '{{.Tag}}'
prerelease: auto
draft: true
extra_files:
- glob: ./PACKAGES/*.deb
- glob: ./PACKAGES/*.rpm
- glob: ./PACKAGES/*.txt
brews:
-
tap:
owner: centrifugal
name: homebrew-centrifugo
folder: Formula
description: "Scalable real-time messaging server in a language-agnostic way"
commit_author:
name: Alexander Emelin
email: frvzmb@gmail.com
install: bin.install "centrifugo"
homepage: "https://github.com/centrifugal/centrifugo"
builds:
- goos:
- linux
- darwin
- freebsd
- windows
goarch:
- amd64
- arm
- arm64
ignore:
- goos: darwin
goarch: arm
- goos: windows
goarch: arm
- goos: freebsd
goarch: arm
- goos: windows
goarch: arm64
- goos: freebsd
goarch: arm64
main: .
ldflags: -s -w -X github.com/centrifugal/centrifugo/v6/internal/build.Version={{.Version}} -X github.com/centrifugal/centrifugo/v6/internal/build.UsageStatsEndpoint={{ .Env.STATS_ENDPOINT }} -X github.com/centrifugal/centrifugo/v6/internal/build.UsageStatsToken={{ .Env.STATS_TOKEN }}
binary: centrifugo
env:
# https://github.com/goreleaser/goreleaser/issues/225
- CGO_ENABLED=0
archives:
- id: centrifugo
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
format: tar.gz
files:
- LICENSE*
- README*
- CHANGELOG*
format_overrides:
- goos: windows
format: zip
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
dist: dist
signs:
-
cmd: gpg
args:
- --output
- $signature
- --detach-sig
- $artifact
signature: ${artifact}.sig
artifacts: none
dockers:
- ids:
- centrifugo
goos: linux
goarch: amd64
image_templates:
- "centrifugo/centrifugo:{{ .Tag }}-amd64"
- "centrifugo/centrifugo:v{{ .Major }}-amd64"
- "centrifugo/centrifugo:v{{ .Major }}.{{ .Minor }}-amd64"
- "centrifugo/centrifugo:latest-amd64"
use: buildx
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.name={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source={{.GitURL}}"
- "--platform=linux/amd64"
- ids:
- centrifugo
goos: linux
goarch: arm64
image_templates:
- "centrifugo/centrifugo:{{ .Tag }}-arm64v8"
- "centrifugo/centrifugo:v{{ .Major }}-arm64v8"
- "centrifugo/centrifugo:v{{ .Major }}.{{ .Minor }}-arm64v8"
- "centrifugo/centrifugo:latest-arm64v8"
use: buildx
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.name={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source={{.GitURL}}"
- "--platform=linux/arm64/v8"
docker_manifests:
- name_template: centrifugo/centrifugo:{{ .Tag }}
image_templates:
- centrifugo/centrifugo:{{ .Tag }}-arm64v8
- centrifugo/centrifugo:{{ .Tag }}-amd64
- name_template: centrifugo/centrifugo:v{{ .Major }}
image_templates:
- centrifugo/centrifugo:{{ .Tag }}-arm64v8
- centrifugo/centrifugo:{{ .Tag }}-amd64
- name_template: centrifugo/centrifugo:v{{ .Major }}.{{ .Minor }}
image_templates:
- centrifugo/centrifugo:{{ .Tag }}-arm64v8
- centrifugo/centrifugo:{{ .Tag }}-amd64
- name_template: centrifugo/centrifugo:latest
image_templates:
- centrifugo/centrifugo:{{ .Tag }}-arm64v8
- centrifugo/centrifugo:{{ .Tag }}-amd64

1568
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

119
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,119 @@
# Contributing to Centrifugo
Thanks for your interest in improving Centrifugo! This document explains how to
get a development environment running and how to get your change merged.
## Where to ask questions
The issue tracker is for **bug reports** and **feature proposals**. If you have a
usage question or want to discuss an idea before opening an issue, please reach
out in the community channels – you'll usually get an answer much faster:
* [Telegram](https://t.me/joinchat/ABFVWBE0AhkyyhREoaboXQ)
* [Discord](https://discord.gg/tYgADKx)
Before opening an issue, please check the [documentation](https://centrifugal.dev)
and [FAQ](https://centrifugal.dev/docs/faq), and search existing issues.
## Reporting bugs and proposing features
Open an issue using one of the [issue templates](https://github.com/centrifugal/centrifugo/issues/new/choose).
For bugs, the most helpful reports include the Centrifugo version, your
configuration (with secrets redacted), the client SDK and version, and a minimal
way to reproduce the problem.
For security vulnerabilities, **do not open a public issue** – follow the process
in [SECURITY.md](SECURITY.md).
## Prerequisites
* [Go](https://go.dev/dl/) – use the version specified in [`go.mod`](go.mod) (or newer).
* [Docker](https://docs.docker.com/get-docker/) and Docker Compose – used to run
Redis, PostgreSQL, Nats, and Kafka for integration tests.
* `make`.
## Building and running locally
Build the binary:
```bash
make build # or: CGO_ENABLED=0 go build
```
Generate a minimal config and start the server:
```bash
./centrifugo genconfig # writes config.json
./centrifugo -c config.json
```
See `./centrifugo --help` for the full list of commands and flags, and the
[configuration docs](https://centrifugal.dev/docs/server/configuration) for
details.
## Running tests
Unit tests (with the race detector):
```bash
make test
```
Integration tests additionally exercise Redis, PostgreSQL, Nats, and Kafka, so
start the dependencies first:
```bash
docker compose up -d --wait
make test-integration
```
This mirrors what CI runs (see [`.github/workflows/test.yml`](.github/workflows/test.yml)),
so running it locally is a good way to reproduce CI failures.
## Linting
Centrifugo uses [`golangci-lint`](https://golangci-lint.run/) pinned to the
version in [`.golangci-lint-version`](.golangci-lint-version):
```bash
make install-lint # installs the pinned version
make lint
```
You can also check for known vulnerabilities in dependencies:
```bash
make vuln # runs govulncheck
```
## Code generation
Some code is generated from Protobuf definitions and from the configuration
schema. If you change a `.proto` file (under `internal/apiproto`,
`internal/proxyproto`, `internal/unigrpc`) or the configuration types, regenerate
the derived files:
```bash
make generate
```
This requires the Protobuf toolchain (`protoc`/`buf`) on your `PATH`. Commit the
regenerated files together with your change.
## Submitting a pull request
1. Fork the repository and create a topic branch.
2. Keep pull requests focused – one logical change per PR is easier to review.
3. Make sure `make lint` and the relevant tests pass before pushing.
4. If you change behavior, update the documentation and add or adjust tests.
5. Fill in the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) and link
any related issue.
For larger or behavior-changing work, it's worth opening an issue (or chatting in
the community channels) first, so the design can be discussed before you invest
time in an implementation.
## License
By contributing to Centrifugo, you agree that your contributions will be licensed
under the [Apache 2.0 License](LICENSE).

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM alpine:3.24
ARG USER=centrifugo
ARG UID=1000
ARG GID=1000
RUN addgroup -S -g $GID $USER && \
adduser -S -G $USER -u $UID $USER
RUN apk --no-cache upgrade && \
apk --no-cache add ca-certificates && \
update-ca-certificates
USER $USER
WORKDIR /centrifugo
COPY centrifugo /usr/local/bin/centrifugo
CMD ["centrifugo"]

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 Centrifugal Labs LTD
Licensed 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.

86
Makefile Normal file
View File

@@ -0,0 +1,86 @@
VERSION := $(shell git describe --tags | sed -e 's/^v//g' | awk -F "-" '{print $$1}')
ITERATION := $(shell git describe --tags --long | awk -F "-" '{print $$2}')
TESTFOLDERS := $(shell go list ./... | grep -v /misc/)
all: test
test:
go test -count=1 -v $(TESTFOLDERS) -cover -race
test-integration:
go test -count=1 -v $(TESTFOLDERS) -cover -race --tags=integration
generate:
bash misc/scripts/generate.sh
go generate ./...
web:
./misc/scripts/update_web.sh
swagger-web:
make generate
./misc/scripts/update_swagger_web.sh
package:
./misc/scripts/package.sh $(VERSION) $(ITERATION)
sync-websocket:
rsync -av --exclude '*/' ../centrifuge/internal/websocket/ internal/websocket/
packagecloud:
make packagecloud-deb
make packagecloud-rpm
packagecloud-deb:
# PACKAGECLOUD_TOKEN env must be set
package_cloud push FZambia/centrifugo/debian/buster PACKAGES/*.deb
package_cloud push FZambia/centrifugo/debian/bullseye PACKAGES/*.deb
package_cloud push FZambia/centrifugo/debian/bookworm PACKAGES/*.deb
package_cloud push FZambia/centrifugo/ubuntu/focal PACKAGES/*.deb
package_cloud push FZambia/centrifugo/ubuntu/jammy PACKAGES/*.deb
package_cloud push FZambia/centrifugo/ubuntu/noble PACKAGES/*.deb
packagecloud-rpm:
# PACKAGECLOUD_TOKEN env must be set
package_cloud push FZambia/centrifugo/el/7 PACKAGES/*.rpm
update-deps:
./misc/scripts/update-deps.sh
deps:
go mod tidy
local-deps:
go mod tidy
go mod download
go mod vendor
GOLANGCI_LINT_VERSION := $(shell cat .golangci-lint-version)
lint:
@if ! command -v golangci-lint >/dev/null 2>&1; then \
echo "golangci-lint not found. Run 'make install-lint' to install $(GOLANGCI_LINT_VERSION)."; \
exit 1; \
fi
@INSTALLED=$$(golangci-lint version --short 2>/dev/null); \
EXPECTED=$$(echo $(GOLANGCI_LINT_VERSION) | sed 's/^v//'); \
if [ "$${INSTALLED%%.*}" != "$${EXPECTED%%.*}" ] || [ "$${INSTALLED#*.}" != "$${EXPECTED#*.}" ]; then \
echo "golangci-lint version mismatch: installed $${INSTALLED}, expected $${EXPECTED}. Run 'make install-lint'."; \
exit 1; \
fi
golangci-lint run --timeout 10m0s --verbose
install-lint:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
vuln:
@if ! command -v govulncheck >/dev/null 2>&1; then \
echo "govulncheck not found, installing..."; \
go install golang.org/x/vuln/cmd/govulncheck@latest; \
fi
govulncheck ./...
build:
CGO_ENABLED=0 go build

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# Centrifugo
_Scalable real-time messaging server in a language-agnostic way._
[![CI](https://github.com/centrifugal/centrifugo/actions/workflows/test.yml/badge.svg)](https://github.com/centrifugal/centrifugo/actions/workflows/test.yml)
[![Release](https://img.shields.io/github/v/release/centrifugal/centrifugo?sort=semver)](https://github.com/centrifugal/centrifugo/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/centrifugo/centrifugo)](https://hub.docker.com/r/centrifugo/centrifugo)
[![License](https://img.shields.io/github/license/centrifugal/centrifugo)](https://github.com/centrifugal/centrifugo/blob/master/LICENSE)
[![Telegram](https://img.shields.io/badge/Telegram-join-26A5E4?logo=telegram&logoColor=white)](https://t.me/joinchat/ABFVWBE0AhkyyhREoaboXQ)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/tYgADKx)
Centrifugo is an open-source scalable real-time messaging server. It instantly delivers messages to application online users connected over supported transports (WebSocket, HTTP-streaming, Server-Sent Events (aka EventSource), GRPC, WebTransport). Centrifugo has the concept of channel subscriptions – so it's a user-facing PUB/SUB server.
Centrifugo is language-agnostic and can be used to build chat apps, live comments, multiplayer games, real-time data visualizations, collaborative tools, AI streaming responses, etc. in combination with any backend. It is well suited for modern architectures and allows decoupling the business logic from the real-time transport layer.
The core idea of Centrifugo is simple – it's a PUB/SUB server on top of modern real-time transports:
<img src="https://centrifugal.dev/img/protocol_pub_sub.png?v=2" alt="Centrifugo PUB/SUB diagram" />
Your backend communicates with Centrifugo over a simple HTTP or GRPC API to publish messages into channels, while clients subscribe to those channels using one of the official SDKs (or the unidirectional approach with no SDK dependency).
## Quick start
Spin up Centrifugo with its embedded admin UI using Docker:
```bash
docker run -it --rm -p 8000:8000 centrifugo/centrifugo:latest centrifugo \
--client.insecure --admin.enabled --admin.insecure
# ^ insecure flags: for local trial only, never use in production
```
Then open http://localhost:8000 to reach the admin UI, where you can watch live connections and publish messages into channels. The `insecure` flags above remove authentication for a frictionless local trial only – never use them in production.
For native binaries, Homebrew, packages, and production configuration see the [installation instructions](https://centrifugal.dev/docs/getting-started/installation), and follow the [quickstart tutorial](https://centrifugal.dev/docs/getting-started/quickstart) to connect your first client.
## Why Centrifugo
The hard part is to make the PUB/SUB concept production-ready, efficient, flexible and available from different application environments. Centrifugo is a mature solution that already helped many projects with adding real-time features and scaling towards many concurrent connections. It provides a set of features not available in other open-source solutions in the area:
* Efficient real-time transports: WebSocket, HTTP-streaming, Server-Sent Events, GRPC, WebTransport (experimental)
* Built-in scalability with Redis (or Redis Cluster, or Redis-compatible storage – ex. AWS Elasticache, Valkey, KeyDB, DragonflyDB, etc), PostgreSQL or Nats.
* Simple HTTP and GRPC server API to communicate with Centrifugo from the app backend
* Asynchronous PostgreSQL and Kafka consumers to support transactional outbox and CDC patterns
* Flexible connection authentication mechanisms: JWT and proxy-like (via request from Centrifugo to the backend)
* Channel subscription multiplexing over a single connection
* Rich subscription model: stream (client-side and server-side), map, shared poll, and proxy subscription streams
* Various channel permission strategies, channel namespace concept
* Hot message history in channels, with automatic message recovery upon reconnect, cache recovery mode (deliver latest publication immediately upon subscription)
* Delta compression in channels based on Fossil algorithm
* Online channel presence information, with join/leave notifications
* A way to send RPC calls to the backend over the real-time connection
* Strict and effective client protocol wrapped by several official SDKs
* JSON and binary Protobuf message transfer, with optimized serialization and built-in batching
* Beautiful embedded admin web UI
* Great observability with lots of Prometheus metrics exposed and official Grafana dashboard
* And much more, visit [Centrifugo documentation site](https://centrifugal.dev)
## Client SDKs
Official SDKs wrap Centrifugo's bidirectional protocol and handle reconnects, subscription state, message recovery, and more across platforms:
| Platform / Language | SDK |
|---------------------------------|-----------------------------------------------------------------------|
| JavaScript (browser, Node.js, React Native) | [centrifuge-js](https://github.com/centrifugal/centrifuge-js) |
| Dart / Flutter (mobile and web) | [centrifuge-dart](https://github.com/centrifugal/centrifuge-dart) |
| Swift / native iOS | [centrifuge-swift](https://github.com/centrifugal/centrifuge-swift) |
| Java (Android, JVM) | [centrifuge-java](https://github.com/centrifugal/centrifuge-java) |
| Python (asyncio) | [centrifuge-python](https://github.com/centrifugal/centrifuge-python) |
| Go | [centrifuge-go](https://github.com/centrifugal/centrifuge-go) |
| .NET / MAUI / Unity [WIP] | [centrifuge-csharp](https://github.com/centrifugal/centrifuge-csharp) |
For simple use cases Centrifugo also supports a unidirectional approach (WebSocket, SSE, HTTP-streaming, GRPC) that needs no SDK – any standard client for the transport works. See the [transports overview](https://centrifugal.dev/docs/transports/overview) and the [client protocol spec](https://centrifugal.dev/docs/transports/client_api) for details.
## Documentation
* [Centrifugo official documentation site](https://centrifugal.dev)
* [Installation instructions](https://centrifugal.dev/docs/getting-started/installation)
* [Getting started tutorial](https://centrifugal.dev/docs/getting-started/quickstart)
* [Design overview and idiomatic usage](https://centrifugal.dev/docs/getting-started/design)
* [Build a WebSocket chat/messenger app with Centrifugo](https://centrifugal.dev/docs/tutorial/intro) tutorial
* [Centrifugal blog](https://centrifugal.dev/blog)
* [FAQ](https://centrifugal.dev/docs/faq)
* [Examples repository](https://github.com/centrifugal/examples)
## Community
* [Telegram](https://t.me/joinchat/ABFVWBE0AhkyyhREoaboXQ)
* [Discord](https://discord.gg/tYgADKx)
* [X (Twitter)](https://twitter.com/centrifugalabs)
Found a bug or have an idea? Open an [issue](https://github.com/centrifugal/centrifugo/issues/new/choose).
Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md).
## Backing
This repository is hosted by [packagecloud.io](https://packagecloud.io/).
<a href="https://packagecloud.io/"><img height="46" width="158" alt="Private NPM registry and Maven, RPM, DEB, PyPi and RubyGem Repository · packagecloud" src="https://packagecloud.io/images/packagecloud-badge.png" /></a>
Also thanks to [JetBrains](https://www.jetbrains.com/) for supporting OSS (most of the code here written in Goland):
<a href="https://www.jetbrains.com/"><img height="140" src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains logo"></a>

85
SECURITY.md Normal file
View File

@@ -0,0 +1,85 @@
# Security Policy
## Reporting Security Vulnerabilities
If you discover a security vulnerability in Centrifugo, please report it directly through GitHub using the **“Report a vulnerability”** button in the repository’s [Security](https://github.com/centrifugal/centrifugo/security) tab. Reports submitted this way are visible only to repository maintainers.
Please do **not** open a public GitHub issue for security-related problems.
When reporting a vulnerability, include as much detail as possible, such as:
* A description of the vulnerability
* Steps to reproduce
* Affected versions or configurations
* Potential impact
We will acknowledge receipt of the report and work to assess the issue promptly.
---
## Vulnerability Detection
Centrifugo uses multiple mechanisms to detect potential security vulnerabilities:
* **Dependency vulnerability scanning**
Third-party dependencies are regularly scanned for known CVEs using automated tooling and public vulnerability databases.
* **Static analysis and linters**
Static analysis tools and security-focused linters are used during development and continuous integration to identify common classes of vulnerabilities.
* **External and community reports**
Vulnerabilities may also be reported by users, security researchers, or downstream consumers via the security contact.
---
## Triage and Assessment
Reported or detected vulnerabilities are reviewed by the project maintainers and assessed based on:
* Severity and exploitability
* Impact on confidentiality, integrity, and availability
* Affected versions and configurations
Issues determined to be security-related are handled as security incidents rather than standard bugs.
---
## Remediation and Patching
* Security fixes are developed following secure development best practices.
* Dependencies may be upgraded or replaced to remediate upstream CVEs.
* Fixes are released in patched versions of Centrifugo.
Users are responsible for upgrading to patched versions in accordance with their own security and compliance requirements.
---
## Disclosure
* Security fixes are documented in release notes where appropriate.
* Coordinated disclosure may be used to allow users time to upgrade before public details are shared.
---
## FedRAMP and Compliance Context
Centrifugo is a self-hosted, open-source software component and is not itself a FedRAMP-authorized service.
Organizations deploying Centrifugo are responsible for:
* Applying updates and security patches
* Performing continuous vulnerability monitoring in their environments
* Ensuring compliance with applicable standards such as NIST SP 800-53
* See also the notes about [FIPS compliance](https://centrifugal.dev/docs/faq#is-centrifugo-fips-compliant)
Centrifugo’s security practices are designed to support integration into regulated and compliance-driven environments.
---
## Supported Versions
Only maintained versions of Centrifugo receive security updates. Users are encouraged to run a currently supported release.
---
*Last updated: 2025-12-17*

18
buf.yaml Normal file
View File

@@ -0,0 +1,18 @@
version: v2
modules:
- path: internal/apiproto
name: buf.build/centrifugo/apiproto
excludes:
- internal/apiproto/swagger
- path: internal/proxyproto
name: buf.build/centrifugo/proxyproto
- path: internal/unigrpc/unistream
name: buf.build/centrifugo/unistream
lint:
use:
- DEFAULT
ignore:
- internal/apiproto/swagger/api.swagger.proto
breaking:
use:
- FILE

127
docker-compose.yml Normal file
View File

@@ -0,0 +1,127 @@
services:
nats:
image: nats:2.10-alpine
container_name: nats_server
command: ["-js"]
ports:
- "4222:4222"
- "8222:8222"
postgres:
image: postgres:16
container_name: postgres
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./misc/postgres/init-replication.sh:/docker-entrypoint-initdb.d/init-replication.sh
command:
- "postgres"
- "-c"
- "wal_level=logical"
- "-c"
- "max_wal_senders=4"
- "-c"
- "hot_standby=on"
- "-c"
- "wal_writer_delay=10ms"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d test"]
interval: 5s
timeout: 5s
retries: 5
# Test/Demo-only setup. NOT suitable for production use.
# The replica wipes its data on every restart (pg_basebackup from primary).
postgres-replica:
image: postgres:16
container_name: postgres_replica
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5433:5432"
depends_on:
postgres:
condition: service_healthy
entrypoint: |
bash -c '
# Test/Demo only: wipe and re-basebackup on every restart.
# In production, use persistent volumes and proper replica management.
rm -rf /var/lib/postgresql/data/*
until pg_isready -h postgres -U test; do sleep 1; done
chown postgres:postgres /var/lib/postgresql/data
chmod 700 /var/lib/postgresql/data
gosu postgres pg_basebackup -h postgres -U test -D /var/lib/postgresql/data -Fp -Xs -R
exec gosu postgres postgres
'
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d test"]
interval: 5s
timeout: 5s
retries: 10
google-pubsub-emulator:
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:572.0.0-emulators
command: gcloud beta emulators pubsub start --host-port=0.0.0.0:8085
ports:
- "8085:8085"
aws-localstack:
image: localstack/localstack:4.14
container_name: localstack
ports:
- "4566:4566" # Edge port for all services.
environment:
- SERVICES=sqs,sns
- DEFAULT_REGION=us-east-1
- DATA_DIR=/tmp/localstack/data
redpanda:
image: redpandadata/redpanda:latest
container_name: redpanda
ports:
- "29092:29092"
- "9644:9644" # Admin API
command:
- redpanda start
- --smp 1
- --memory 1G
- --overprovisioned
- --node-id 0
- --check=false
- --kafka-addr PLAINTEXT://0.0.0.0:29092
- --advertise-kafka-addr PLAINTEXT://localhost:29092
healthcheck:
test: ["CMD", "rpk", "cluster", "info"]
interval: 2s
timeout: 5s
retries: 10
profiles: ["redpanda"]
kafka:
image: confluentinc/cp-kafka:8.0.0
ports:
- "29092:29092"
environment:
KAFKA_NODE_ID: 1
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,CONTROLLER://kafka:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:29092
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
CLUSTER_ID: centrifugo-kafka
redis:
image: redis:${REDIS_VERSION:-7}-alpine
ports:
- "6379:6379"
volumes:
postgres_data:

145
go.mod Normal file
View File

@@ -0,0 +1,145 @@
module github.com/centrifugal/centrifugo/v6
go 1.26.0
require (
cloud.google.com/go/pubsub/v2 v2.7.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.10.0
github.com/FZambia/eagle v0.2.0
github.com/FZambia/statik v0.1.2-0.20180217151304-b9f012bb2a1b
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2/config v1.33.4
github.com/aws/aws-sdk-go-v2/credentials v1.20.4
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.0
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0
github.com/aws/smithy-go v1.28.1
github.com/centrifugal/centrifuge v0.39.2-0.20260914154348-ee33344b78aa
github.com/centrifugal/protocol v0.22.1
github.com/cristalhq/jwt/v5 v5.4.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gobwas/glob v1.0.0
github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2
github.com/hashicorp/go-envparse v0.1.0
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.11.0
github.com/joho/godotenv v1.5.1
github.com/justinas/alice v1.2.0
github.com/mattn/go-isatty v0.0.24
github.com/nats-io/nats.go v1.53.1
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/prometheus/client_golang v1.24.1
github.com/quic-go/quic-go v0.62.0
github.com/quic-go/webtransport-go v0.13.0
github.com/rakutentech/jwk-go v1.2.0
github.com/rs/zerolog v1.35.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/tidwall/gjson v1.19.0
github.com/tidwall/sjson v1.2.5
github.com/twmb/franz-go v1.21.6
github.com/twmb/franz-go/pkg/kadm v1.18.0
github.com/twmb/franz-go/pkg/kmsg v1.13.1
github.com/valyala/fasttemplate v1.2.2
github.com/yuin/goldmark v1.8.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
go.uber.org/automaxprocs v1.6.0
golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/time v0.16.0
google.golang.org/api v0.298.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/go-amqp v1.7.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.9.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/dunglas/httpsfv v1.1.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect
github.com/googleapis/gax-go/v2 v2.24.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/maypok86/otter/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pierrec/lz4/v4 v4.1.29 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/quagmt/udecimal v1.10.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
google.golang.org/genproto v0.0.0-20260911204522-f61a6ca850bd // indirect
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/redis/rueidis v1.0.77
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260911204522-f61a6ca850bd // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260911204522-f61a6ca850bd // indirect
gopkg.in/yaml.v3 v3.0.1
)

437
go.sum Normal file
View File

@@ -0,0 +1,437 @@
cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U=
cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0=
cloud.google.com/go/pubsub/v2 v2.7.0 h1:MFrBTZZa6PDWZzCi4NJRsHKMm2w0a4oAaYNqwjgbQTE=
cloud.google.com/go/pubsub/v2 v2.7.0/go.mod h1:JaFvWNVRk3Knoil/4M1ECeLOaI9D8drbmJWypQlK5aM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.10.0 h1:kE5kpeiSqu4jcCQ/sWuyggMXJ/pT6oQ99+8hwPmyeJ0=
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.10.0/go.mod h1:IAN3Z0DMtehoxoQQnfqg1891z1P7GNoDryKtFcAyMBI=
github.com/Azure/go-amqp v1.7.0 h1:9VlH/LEWr386XWWJRNON0eslFqSClYBXP4HewvIqkDQ=
github.com/Azure/go-amqp v1.7.0/go.mod h1:pCJaHsvRlmmFUpxyQbh2qPkUFqYJeRBTqJSHKJadvPg=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.9.0 h1:MDT4FxAPve5FnYn6vOL1r7RCRDG+l9cI7a5LlCuHsqA=
github.com/AzureAD/microsoft-authentication-library-for-go v1.9.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/FZambia/eagle v0.2.0 h1:1kQaZpJvbkvAXFRE/9K2ucBMuVqo+E29EMLYB74hIis=
github.com/FZambia/eagle v0.2.0/go.mod h1:LKMYBwGYhao5sJI0TppvQ4SvvldFj9gITxrl8NvGwG0=
github.com/FZambia/statik v0.1.2-0.20180217151304-b9f012bb2a1b h1:D3CXZ/tXFtPMSN5FlhHVezJJp9eqDPR3m27OVptqZYE=
github.com/FZambia/statik v0.1.2-0.20180217151304-b9f012bb2a1b/go.mod h1:EqC55Pa/sH33dUeH/rkKY8EHRfnm2JyV+ORPzURdGm0=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0=
github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs=
github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.0 h1:39EpbrAPFSOPYc9FVr2ki84cLB/9C5nC03aL7ope2rU=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.0/go.mod h1:yErwLsJkArgQLSGWtLjjwlpvlLK4+c9h0jDZZVN02hw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/centrifugal/centrifuge v0.39.2-0.20260914154348-ee33344b78aa h1:Y0Bn273bYNyQIYB1HlPHTHzbcAZQiWHuOktaXIuoy9Y=
github.com/centrifugal/centrifuge v0.39.2-0.20260914154348-ee33344b78aa/go.mod h1:pWdNmGiYGH3zNxGzdnJOOeSVIHG1Ai7yofBaYg9obA4=
github.com/centrifugal/protocol v0.22.1 h1:m5Nuf396H3h3CbE3CkNTohbrhn55PFwKIZ/fH+LvgsI=
github.com/centrifugal/protocol v0.22.1/go.mod h1:gL7ftIACFuHhymAeIK9Vf+ombUpscN7J/dKfdNjhNiw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI=
github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dunglas/httpsfv v1.1.1 h1:HoSs101zIE9I23DlqlmljJ/OIi7ILwrH347pXhRZdxI=
github.com/dunglas/httpsfv v1.1.1/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0 h1:my2ucqBZmv+cWHIhZNSIYKzgN8EBGyHdC7zD5sASRAg=
github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c=
github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.24.1 h1:AtqTN21IXMMWo99LiEVAiBfNNQmO40d8xUfZI640mc0=
github.com/googleapis/gax-go/v2 v2.24.1/go.mod h1:bWeBei0NVwaNZKb2y1HUBS7gLXIF3/Tu3pq7j8D2Tb0=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg=
github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/quagmt/udecimal v1.10.1 h1:h4t88kp/x21hCdhRhzBNW9e7D50l7pLEc1iwIEsCB28=
github.com/quagmt/udecimal v1.10.1/go.mod h1:ScmJ/xTGZcEoYiyMMzgDLn79PEJHcMBiJ4NNRT3FirA=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/quic-go/webtransport-go v0.13.0 h1:RJLrTUHlTj8jJaQlQJUy0z0Mf7u1fVM0I6L1b9pe2M0=
github.com/quic-go/webtransport-go v0.13.0/go.mod h1:K83X9YHbAqgSLO6ikS6BXCMdWOvqh9JTHALulvb2JVk=
github.com/rakutentech/jwk-go v1.2.0 h1:vNJwedPkRR+32V5WGNj0JP4COes93BGERvzQLBjLy4c=
github.com/rakutentech/jwk-go v1.2.0/go.mod h1:pI0bYVntqaJ27RCpaC75MTUacheW0Rk4+8XzWWe1OWM=
github.com/redis/rueidis v1.0.77 h1:ZR41bgJcm7oRFb3aSDPrRhC0eonDSrPzjvvZvHIlNjM=
github.com/redis/rueidis v1.0.77/go.mod h1:L8mnCQJJaSNL6I4pIR6Rz732HTGS9vmuXm0yT9dRvjo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3 h1:/4/IJi5iyTdh6mqOUaASW148HQpujYiHl0Wl78dSOSc=
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3/go.mod h1:aJIMhRsunltJR926EB2MUg8qHemFQDreSB33pyto2Ps=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twmb/franz-go v1.21.6 h1:+v0dQJVIIuw9uPmPWmPrkoUHs1pPeV8MSwA4eU/Y2kY=
github.com/twmb/franz-go v1.21.6/go.mod h1:wMepkgCatAdV9vCsuwM+wr+C1fl7KV/41+uHGAjt/wc=
github.com/twmb/franz-go/pkg/kadm v1.18.0 h1:WRf/LZmDdcDXwX7WMbtDU++v+b3NzYh2bCGoPMmzirw=
github.com/twmb/franz-go/pkg/kadm v1.18.0/go.mod h1:XeLhGoLXLFzK8/ryv5FfpxPxGwj4oFEGpPJMB/x6KDE=
github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI=
github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.298.0 h1:YW18RkHBMZBA1ergX0m4biagzgbiPTb2uTsRsDPWNRY=
google.golang.org/api v0.298.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20260911204522-f61a6ca850bd h1:s/EXdxMDrmT++u0VepJKO4U6dzOPJVRdHKmrazC1Qxg=
google.golang.org/genproto v0.0.0-20260911204522-f61a6ca850bd/go.mod h1:JfZ1Hyx5mAJgX/ip0vEicmPvzE6rIrbacB7rHMfjD7Q=
google.golang.org/genproto/googleapis/api v0.0.0-20260911204522-f61a6ca850bd h1:jgCQeWccunvocKNK2RjhQHNiSjSeMMuBpQ/5pbP+2lA=
google.golang.org/genproto/googleapis/api v0.0.0-20260911204522-f61a6ca850bd/go.mod h1:r4KD2hOq82JBWpTWkJ9NZLf6EwmRvAPIFmk7hPNtd+0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260911204522-f61a6ca850bd h1:29LZPNIJg+GEmJwZXruJJRwqy/wnWAYZXDtROToV/aQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260911204522-f61a6ca850bd/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

201
internal/admin/handlers.go Normal file
View File

@@ -0,0 +1,201 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/reverseproxy"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/webui"
"github.com/centrifugal/centrifuge"
"github.com/gorilla/securecookie"
"github.com/rs/zerolog/log"
)
type Config = configtypes.Admin
// Handler handles admin web UI endpoints.
type Handler struct {
mux *http.ServeMux
node *centrifuge.Node
config configtypes.Admin
}
// NewHandler creates new Handler.
func NewHandler(n *centrifuge.Node, apiExecutor *api.Executor, c Config) *Handler {
h := &Handler{
node: n,
config: c,
}
mux := http.NewServeMux()
prefix := strings.TrimRight(h.config.HandlerPrefix, "/")
mux.Handle(prefix+"/admin/init", http.HandlerFunc(h.initHandler))
// Throttle repeated failed password attempts per client IP to slow brute-force
// of admin.password. Only failures count and each IP is tracked independently,
// so a valid login is never throttled - including while another source attacks.
//
// This is best-effort, in-process protection only. The admin auth endpoint is
// intended to be reachable by trusted operators, and Centrifugo recommends
// restricting access to it at the infrastructure level (firewall rules, a
// private network, an authenticating reverse proxy, etc.). The throttle here
// raises the bar for brute-force but is not a substitute for that: the primary
// protection of the admin endpoint must be done at the infrastructure level.
authThrottle := middleware.NewAuthThrottle(10, time.Minute, nil)
mux.Handle(prefix+"/admin/auth", middleware.Post(authThrottle.Middleware(http.HandlerFunc(h.authHandler))))
mux.Handle(prefix+"/admin/api", middleware.Post(h.adminSecureTokenAuth(api.NewHandler(n, apiExecutor, api.Config{}).OldRoute())))
webPrefix := prefix + "/"
if c.WebProxyAddress != "" {
log.Info().Str("address", c.WebProxyAddress).Msg("using admin web reverse proxy")
proxy, err := reverseproxy.New(c.WebProxyAddress)
if err != nil {
log.Fatal().Err(err).Msg("error creating admin web reverse proxy")
}
mux.HandleFunc(webPrefix, reverseproxy.ProxyRequestHandler(proxy))
} else if c.WebPath != "" {
mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(http.Dir(c.WebPath))))
} else {
mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(webui.FS)))
}
h.mux = mux
return h
}
func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(rw, r)
}
func (s *Handler) adminSecureTokenAuth(h http.Handler) http.Handler {
secret := s.config.Secret
insecure := s.config.Insecure
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if insecure {
h.ServeHTTP(w, r)
return
}
if secret == "" {
log.Error().Msg("no admin secret key found in configuration")
w.WriteHeader(http.StatusUnauthorized)
return
}
var token string
authorization := r.Header.Get("Authorization")
if authorization != "" {
parts := strings.Fields(authorization)
if len(parts) != 2 {
w.WriteHeader(http.StatusUnauthorized)
return
}
authMethod := strings.ToLower(parts[0])
if authMethod != "token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
token = parts[1]
}
if token == "" || !checkSecureAdminToken(secret, token) {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
// initHandler allows to get admin web interface settings.
func (s *Handler) initHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"insecure": s.config.Insecure,
"edition": "oss",
}
secret := s.config.Secret
authenticated := false
if s.config.Insecure {
authenticated = true
} else if secret != "" {
var token string
authorization := r.Header.Get("Authorization")
if authorization != "" {
parts := strings.Fields(authorization)
if len(parts) == 2 && strings.ToLower(parts[0]) == "token" {
token = parts[1]
}
}
if token != "" && checkSecureAdminToken(secret, token) {
authenticated = true
}
}
resp["authenticated"] = authenticated
_ = json.NewEncoder(w).Encode(resp)
}
// authHandler allows to get admin web interface token.
func (s *Handler) authHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) // 1MB limit.
formPassword := r.FormValue("password")
password := s.config.Password
secret := s.config.Secret
if password == "" || secret == "" {
log.Error().Msg("admin_password and admin_secret must be set in configuration")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if tools.SecureCompareString(formPassword, password) {
w.Header().Set("Content-Type", "application/json")
token, err := generateSecureAdminToken(secret)
if err != nil {
log.Error().Msgf("error generating admin token: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := map[string]string{
"token": token,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.Error(w, "Bad Request", http.StatusBadRequest)
}
const (
// AdminTokenKey is a key for admin authorization token.
secureAdminTokenKey = "token"
// AdminTokenValue is a value for secure admin authorization token.
secureAdminTokenValue = "authorized"
)
// generateSecureAdminToken generates admin authentication token.
func generateSecureAdminToken(secret string) (string, error) {
s := securecookie.New([]byte(secret), nil)
return s.Encode(secureAdminTokenKey, secureAdminTokenValue)
}
// checkSecureAdminToken checks admin connection token which Centrifugo returns after admin login.
func checkSecureAdminToken(secret string, token string) bool {
s := securecookie.New([]byte(secret), nil)
var val string
err := s.Decode(secureAdminTokenKey, token, &val)
if err != nil {
return false
}
if val != secureAdminTokenValue {
return false
}
return true
}

View File

@@ -0,0 +1,218 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
// TestNewHandler ensures NewHandler sets up routes and proxy handling correctly.
func TestNewHandler(t *testing.T) {
node := &centrifuge.Node{}
cfg := Config{HandlerPrefix: "/prefix", WebProxyAddress: "", WebPath: ""}
handler := NewHandler(node, nil, cfg)
require.NotNil(t, handler)
require.NotNil(t, handler.mux)
// Validate init route is registered.
req := httptest.NewRequest("GET", "/prefix/admin/init", nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestInitHandler checks the initHandler returns correct settings.
func TestInitHandler(t *testing.T) {
config := Config{}
handler := &Handler{config: config}
req := httptest.NewRequest("GET", "/admin/init", nil)
resp := httptest.NewRecorder()
handler.initHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]any
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.Equal(t, "oss", response["edition"])
require.Equal(t, false, response["insecure"])
require.Equal(t, false, response["authenticated"])
}
func TestInitHandler_Insecure(t *testing.T) {
config := Config{Insecure: true}
handler := &Handler{config: config}
req := httptest.NewRequest("GET", "/admin/init", nil)
resp := httptest.NewRecorder()
handler.initHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]any
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.Equal(t, "oss", response["edition"])
require.Equal(t, true, response["insecure"])
require.Equal(t, true, response["authenticated"]) // Since insecure on.
}
// TestAuthHandler_NoPasswordOrSecret tests authHandler error when password or secret is missing.
func TestAuthHandler_NoPasswordOrSecret(t *testing.T) {
config := Config{Password: "", Secret: ""}
handler := &Handler{config: config}
req := httptest.NewRequest("POST", "/admin/auth", nil)
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
// TestAuthHandler_ValidPassword tests authHandler token generation with valid password.
func TestAuthHandler_ValidPassword(t *testing.T) {
config := Config{Password: "test-password", Secret: "test-secret"}
handler := &Handler{config: config}
form := url.Values{}
form.Add("password", "test-password")
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]string
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.NotEmpty(t, response["token"])
}
// TestAuthHandler_InvalidPassword tests authHandler rejection with invalid password.
func TestAuthHandler_InvalidPassword(t *testing.T) {
config := Config{Password: "test-password", Secret: "test-secret"}
handler := &Handler{config: config}
form := url.Values{}
form.Add("password", "wrong-password")
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
// TestAuthHandler_Throttled ensures the /admin/auth route throttles repeated
// failed password attempts per client IP, without blocking a valid login from
// another IP while an attacker is brute-forcing.
func TestAuthHandler_Throttled(t *testing.T) {
node := &centrifuge.Node{}
cfg := Config{Password: "test-password", Secret: "test-secret"}
handler := NewHandler(node, nil, cfg)
post := func(ip, password string) int {
form := url.Values{}
form.Add("password", password)
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
req.Header.Set("X-Real-IP", ip)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp.Code
}
// The default limit is 10 failures per IP per window.
for i := 0; i < 10; i++ {
require.Equal(t, http.StatusBadRequest, post("6.6.6.6", "wrong"), "attempt %d", i)
}
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "wrong"))
// The attacker cannot tell a correct guess from a throttled one.
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "test-password"))
// A valid login from a different IP still succeeds during the attack.
require.Equal(t, http.StatusOK, post("7.7.7.7", "test-password"))
}
// TestAdminSecureTokenAuth_InsecureMode tests adminSecureTokenAuth allows request in insecure mode.
func TestAdminSecureTokenAuth_InsecureMode(t *testing.T) {
config := Config{Insecure: true}
handler := &Handler{config: config}
// Mocked handler that should be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestAdminSecureTokenAuth_MissingToken tests adminSecureTokenAuth rejection when token is missing.
func TestAdminSecureTokenAuth_MissingToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should not be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}
// TestAdminSecureTokenAuth_ValidToken tests adminSecureTokenAuth with valid token.
func TestAdminSecureTokenAuth_ValidToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Generate a valid token
token, err := generateSecureAdminToken("test-secret")
require.NoError(t, err)
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
req.Header.Set("Authorization", "token "+token)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestAdminSecureTokenAuth_InvalidToken tests adminSecureTokenAuth rejection with invalid token.
func TestAdminSecureTokenAuth_InvalidToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should not be invoked.
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Fail(t, "final handler should not be invoked")
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
req.Header.Set("Authorization", "token invalid-token")
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}

1340
internal/api/api.go Normal file

File diff suppressed because it is too large Load Diff

747
internal/api/api_test.go Normal file
View File

@@ -0,0 +1,747 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"os"
"testing"
"time"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
// Initialize metrics with a custom registry for tests to avoid conflicts
registry := prometheus.NewRegistry()
_ = metrics.Init(metrics.Config{
Registerer: registry,
})
os.Exit(m.Run())
}
func nodeWithMemoryEngine() *centrifuge.Node {
n, err := centrifuge.New(centrifuge.Config{})
if err != nil {
panic(err)
}
err = n.Run()
if err != nil {
panic(err)
}
return n
}
type testSurveyCaller struct{}
func (t testSurveyCaller) Channels(_ context.Context, _ *ChannelsRequest) (map[string]*ChannelInfo, error) {
return nil, nil
}
func (t testSurveyCaller) Connections(_ context.Context, _ *ConnectionsRequest) (map[string]*ConnectionInfo, error) {
return nil, nil
}
func TestPublishAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Publish(context.Background(), &PublishRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test"})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test", Data: []byte("test")})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{Channel: "test:test", Data: []byte("test")})
require.Equal(t, ErrorUnknownChannel, resp.Error)
}
// TestBatchAPINilCommand guards against a crash on a nil batch command (e.g. a
// JSON `null` array element). In parallel mode each command runs in its own
// goroutine, so a nil-deref would escape net/http's recover and crash the node.
func TestBatchAPINilCommand(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
for _, parallel := range []bool{false, true} {
resp := api.Batch(context.Background(), &BatchRequest{
Parallel: parallel,
Commands: []*Command{
nil,
{Publish: &PublishRequest{Channel: "test", Data: []byte("x")}},
},
})
require.Len(t, resp.Replies, 2)
require.Equal(t, ErrorBadRequest, resp.Replies[0].Error)
require.Nil(t, resp.Replies[1].Error)
}
}
func TestBroadcastAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Broadcast(context.Background(), &BroadcastRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
// Empty data validation now happens per-channel
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test"}})
require.Nil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[0].Error.Code)
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test"}, Data: []byte("test")})
require.Nil(t, resp.Error)
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test:test"}, Data: []byte("test")})
require.Nil(t, resp.Error)
require.Equal(t, ErrorUnknownChannel, resp.Result.Responses[0].Error)
resp = api.Broadcast(context.Background(), &BroadcastRequest{Channels: []string{"test", "test:test"}, Data: []byte("test")})
require.Nil(t, resp.Error)
require.Equal(t, ErrorUnknownChannel, resp.Result.Responses[1].Error)
}
func TestPublishAPI_DataFormat_JSON(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatJSON
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Valid JSON should succeed
resp := api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`{"key": "value"}`),
})
require.Nil(t, resp.Error)
// Valid JSON array should succeed
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`[1, 2, 3]`),
})
require.Nil(t, resp.Error)
// Valid JSON primitives should succeed
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`"string"`),
})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`123`),
})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`true`),
})
require.Nil(t, resp.Error)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`null`),
})
require.Nil(t, resp.Error)
// Invalid JSON should fail
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte(`not valid json`),
})
require.NotNil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
// Empty data with JSON format should fail
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{},
})
require.NotNil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
}
func TestPublishAPI_DataFormat_Binary(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatBinary
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Binary data should succeed
resp := api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{0x01, 0x02, 0x03},
})
require.Nil(t, resp.Error)
// Empty data with binary format should succeed
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{},
})
require.Nil(t, resp.Error)
// Any data should succeed with binary format
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte("any text data"),
})
require.Nil(t, resp.Error)
}
func TestPublishAPI_DataFormat_Default(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
// Default format (empty string) - should reject empty data
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Non-empty data should succeed
resp := api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte("any data"),
})
require.Nil(t, resp.Error)
// Empty data should fail (current behavior preserved)
resp = api.Publish(context.Background(), &PublishRequest{
Channel: "test",
Data: []byte{},
})
require.NotNil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Error.Code)
}
func TestBroadcastAPI_DataFormat_JSON(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatJSON
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Valid JSON should succeed
resp := api.Broadcast(context.Background(), &BroadcastRequest{
Channels: []string{"test", "test2"},
Data: []byte(`{"key": "value"}`),
})
require.Nil(t, resp.Error)
require.Nil(t, resp.Result.Responses[0].Error)
require.Nil(t, resp.Result.Responses[1].Error)
// Invalid JSON should fail for all channels
resp = api.Broadcast(context.Background(), &BroadcastRequest{
Channels: []string{"test", "test2"},
Data: []byte(`not valid json`),
})
require.Nil(t, resp.Error)
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[0].Error.Code)
require.Equal(t, ErrorBadRequest.Code, resp.Result.Responses[1].Error.Code)
}
func TestBroadcastAPI_DataFormat_Binary(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Channel.WithoutNamespace.PublicationDataFormat = configtypes.PublicationDataFormatBinary
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// Empty data should succeed with binary format
resp := api.Broadcast(context.Background(), &BroadcastRequest{
Channels: []string{"test", "test2"},
Data: []byte{},
})
require.Nil(t, resp.Error)
require.Nil(t, resp.Result.Responses[0].Error)
require.Nil(t, resp.Result.Responses[1].Error)
}
func TestHistoryAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.History(context.Background(), &HistoryRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.History(context.Background(), &HistoryRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.HistorySize = 1
cfg.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(1 * time.Second)
_ = cfgContainer.Reload(cfg)
resp = api.History(context.Background(), &HistoryRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestHistoryRemoveAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.HistoryRemove(context.Background(), &HistoryRemoveRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.HistoryRemove(context.Background(), &HistoryRemoveRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.HistorySize = 1
cfg.Channel.WithoutNamespace.HistoryTTL = configtypes.Duration(time.Second)
_ = cfgContainer.Reload(cfg)
resp = api.HistoryRemove(context.Background(), &HistoryRemoveRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestPresenceAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Presence(context.Background(), &PresenceRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.Presence(context.Background(), &PresenceRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.Presence = true
_ = cfgContainer.Reload(cfg)
resp = api.Presence(context.Background(), &PresenceRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestPresenceStatsAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.PresenceStats(context.Background(), &PresenceStatsRequest{})
require.Equal(t, ErrorBadRequest, resp.Error)
resp = api.PresenceStats(context.Background(), &PresenceStatsRequest{Channel: "test"})
require.Equal(t, ErrorNotAvailable, resp.Error)
cfg = cfgContainer.Config()
cfg.Channel.WithoutNamespace.Presence = true
_ = cfgContainer.Reload(cfg)
resp = api.PresenceStats(context.Background(), &PresenceStatsRequest{Channel: "test"})
require.Nil(t, resp.Error)
}
func TestDisconnectAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Disconnect(context.Background(), &DisconnectRequest{
User: "test",
})
require.Nil(t, resp.Error)
}
func TestUnsubscribeAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Unsubscribe(context.Background(), &UnsubscribeRequest{
User: "test",
Channel: "test",
})
require.Nil(t, resp.Error)
}
func TestRefreshAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Refresh(context.Background(), &RefreshRequest{
User: "test",
})
require.Nil(t, resp.Error)
}
func TestSubscribeAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Subscribe(context.Background(), &SubscribeRequest{
User: "test",
Channel: "test",
})
require.Nil(t, resp.Error)
}
func TestInfoAPI(t *testing.T) {
node := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Info(context.Background(), &InfoRequest{})
require.Nil(t, resp.Error)
}
// configWithNamespace returns a DefaultConfig with a single namespace having the
// given name and subscription_type. Used to test the subscription_type gates on
// publish/map_*/shared_poll_* API endpoints. Fills the minimum mandatory fields
// (map.mode for map types, global shared_poll.hmac_secret_key for shared_poll)
// so the config passes Validate().
func configWithNamespace(name, subscriptionType string) config.Config {
cfg := config.DefaultConfig()
chOpts := configtypes.ChannelOptions{
SubscriptionType: subscriptionType,
}
switch subscriptionType {
case "map":
// Persistent mode requires no key_ttl and is the simplest minimum.
chOpts.Map = configtypes.MapConfig{Mode: "persistent"}
case "map_clients", "map_users":
// Persistent mode is forbidden here (presence entries need TTL-based
// cleanup), so use ephemeral with a key_ttl.
chOpts.Map = configtypes.MapConfig{
Mode: "ephemeral",
KeyTTL: configtypes.Duration(time.Minute),
}
case "shared_poll":
// Global HMAC secret required for any shared_poll namespace.
cfg.SharedPoll.HMACSecretKey = "test-secret"
// Default refresh proxy must have an endpoint when no explicit
// proxy_name is set on the namespace.
cfg.Channel.Proxy.SharedPollRefresh = configtypes.Proxy{
Endpoint: "http://127.0.0.1:0/test",
Timeout: configtypes.Duration(time.Second),
}
chOpts.SharedPoll = configtypes.SharedPollConfig{Mode: "versioned"}
}
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
{
Name: name,
ChannelOptions: chOpts,
},
}
return cfg
}
// TestPublishAPI_RejectsMapAndSharedPollNamespaces ensures the stream Publish
// endpoint rejects calls aimed at namespaces configured for map or shared poll
// subscriptions — those have their own dedicated APIs.
func TestPublishAPI_RejectsMapAndSharedPollNamespaces(t *testing.T) {
cases := []struct {
subscriptionType string
}{
{"map"},
{"map_clients"},
{"map_users"},
{"shared_poll"},
}
for _, c := range cases {
t.Run(c.subscriptionType, func(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", c.subscriptionType)
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.Publish(context.Background(), &PublishRequest{Channel: "ns:test", Data: []byte("data")})
require.Equal(t, ErrorBadRequest, resp.Error)
})
}
}
// TestMapPublishAPI_RejectsNonMapNamespaces ensures map_publish rejects calls
// against namespaces that aren't configured for map subscriptions.
func TestMapPublishAPI_RejectsNonMapNamespaces(t *testing.T) {
cases := []struct {
subscriptionType string
}{
{""},
{"stream"},
{"shared_poll"},
}
for _, c := range cases {
t.Run("type="+c.subscriptionType, func(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", c.subscriptionType)
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.MapPublish(context.Background(), &MapPublishRequest{
Channel: "ns:test",
Key: "k",
Data: []byte(`{"v":1}`),
})
require.Equal(t, ErrorBadRequest, resp.Error)
})
}
}
// TestMapPublishAPI_RejectsInvalidKeyMode ensures key_mode is validated against
// the allowed set ("", "if_new", "if_exists").
func TestMapPublishAPI_RejectsInvalidKeyMode(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", "map")
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
resp := api.MapPublish(context.Background(), &MapPublishRequest{
Channel: "ns:test",
Key: "k",
Data: []byte(`{"v":1}`),
KeyMode: "bogus",
})
require.Equal(t, ErrorBadRequest, resp.Error)
}
// TestMapAPIs_RejectNonMapNamespaces ensures the other map API endpoints
// (remove / read_state / read_stream / stats / clear) reject calls against
// namespaces that aren't configured for map subscriptions, instead of leaking
// internal errors from the broker.
func TestMapAPIs_RejectNonMapNamespaces(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := configWithNamespace("ns", "") // default = stream
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
rmResp := api.MapRemove(context.Background(), &MapRemoveRequest{Channel: "ns:test", Key: "k"})
require.Equal(t, ErrorBadRequest, rmResp.Error)
rsResp := api.MapReadState(context.Background(), &MapReadStateRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, rsResp.Error)
rstrResp := api.MapReadStream(context.Background(), &MapReadStreamRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, rstrResp.Error)
stResp := api.MapStats(context.Background(), &MapStatsRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, stResp.Error)
clResp := api.MapClear(context.Background(), &MapClearRequest{Channel: "ns:test"})
require.Equal(t, ErrorBadRequest, clResp.Error)
}
// TestMapAPIs_RejectUnknownChannel ensures the map API endpoints return
// ErrorUnknownChannel instead of leaking internal errors when the channel's
// namespace doesn't exist.
func TestMapAPIs_RejectUnknownChannel(t *testing.T) {
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
// "missing:test" — namespace "missing" not configured.
pubResp := api.MapPublish(context.Background(), &MapPublishRequest{Channel: "missing:test", Key: "k", Data: []byte(`{}`)})
require.Equal(t, ErrorUnknownChannel, pubResp.Error)
rmResp := api.MapRemove(context.Background(), &MapRemoveRequest{Channel: "missing:test", Key: "k"})
require.Equal(t, ErrorUnknownChannel, rmResp.Error)
rsResp := api.MapReadState(context.Background(), &MapReadStateRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, rsResp.Error)
rstrResp := api.MapReadStream(context.Background(), &MapReadStreamRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, rstrResp.Error)
stResp := api.MapStats(context.Background(), &MapStatsRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, stResp.Error)
clResp := api.MapClear(context.Background(), &MapClearRequest{Channel: "missing:test"})
require.Equal(t, ErrorUnknownChannel, clResp.Error)
}
// nodeWithMapSupport builds a node able to actually serve map operations for
// channels of the given config container, so that valid publishes really reach
// the map broker instead of failing on options resolution.
func nodeWithMapSupport(cfgContainer *config.Container) *centrifuge.Node {
n, err := centrifuge.New(centrifuge.Config{
Map: centrifuge.MapConfig{
GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions {
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
if err != nil || !ok {
return centrifuge.MapChannelOptions{}
}
var mode centrifuge.MapMode
switch chOpts.Map.Mode {
case "ephemeral":
mode = centrifuge.MapModeEphemeral
case "recoverable":
mode = centrifuge.MapModeRecoverable
case "persistent":
mode = centrifuge.MapModePersistent
}
return centrifuge.MapChannelOptions{Mode: mode, KeyTTL: chOpts.Map.KeyTTL.ToDuration()}
},
},
})
if err != nil {
panic(err)
}
if err = n.Run(); err != nil {
panic(err)
}
return n
}
// TestMapPublishAPI_DataFormat ensures map_publish applies the namespace
// publication_data_format policy, same as the stream Publish endpoint does.
func TestMapPublishAPI_DataFormat(t *testing.T) {
cases := []struct {
format string
data []byte
b64Data string
wantReject bool
}{
{configtypes.PublicationDataFormatJSONObject, []byte(`{"v":1}`), "", false},
{configtypes.PublicationDataFormatJSONObject, []byte(`[1,2]`), "", true},
{configtypes.PublicationDataFormatJSONObject, []byte(`not-json-object`), "", true},
{configtypes.PublicationDataFormatJSON, []byte(`[1,2]`), "", false},
{configtypes.PublicationDataFormatJSON, []byte(`nope`), "", true},
{configtypes.PublicationDataFormatBinary, nil, "", false},
{"", []byte(`whatever`), "", false},
{"", nil, "", true}, // default format rejects empty data
// b64data must be validated after decoding, not before.
{configtypes.PublicationDataFormatJSONObject, nil, base64.StdEncoding.EncodeToString([]byte(`{"v":1}`)), false},
{configtypes.PublicationDataFormatJSONObject, nil, base64.StdEncoding.EncodeToString([]byte(`nope`)), true},
}
for i, c := range cases {
t.Run(fmt.Sprintf("%d_format=%q", i, c.format), func(t *testing.T) {
cfg := configWithNamespace("ns", "map")
cfg.Channel.Namespaces[0].PublicationDataFormat = c.format
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
node := nodeWithMapSupport(cfgContainer)
defer func() { _ = node.Shutdown(context.Background()) }()
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test"})
resp := api.MapPublish(context.Background(), &MapPublishRequest{
Channel: "ns:test", Key: "k", Data: c.data, B64Data: c.b64Data,
})
if c.wantReject {
require.Equal(t, ErrorBadRequest, resp.Error)
} else {
require.Nil(t, resp.Error)
}
})
}
}
// TestSharedPollPublishAPI_DataFormat ensures shared_poll_publish applies the
// namespace publication_data_format policy.
func TestSharedPollPublishAPI_DataFormat(t *testing.T) {
cases := []struct {
format string
data []byte
wantReject bool
}{
{configtypes.PublicationDataFormatJSONObject, []byte(`{"v":1}`), false},
{configtypes.PublicationDataFormatJSONObject, []byte(`nope`), true},
{configtypes.PublicationDataFormatJSON, []byte(`nope`), true},
{configtypes.PublicationDataFormatBinary, nil, false},
{"", nil, true}, // default format rejects empty data
}
for i, c := range cases {
t.Run(fmt.Sprintf("%d_format=%q", i, c.format), func(t *testing.T) {
cfg := configWithNamespace("ns", "shared_poll")
cfg.Channel.Namespaces[0].PublicationDataFormat = c.format
cfg.Channel.Namespaces[0].SharedPoll.PublishEnabled = true
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
node := nodeWithMemoryEngine()
defer func() { _ = node.Shutdown(context.Background()) }()
api := NewExecutor(node, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test"})
resp := api.SharedPollPublish(context.Background(), &SharedPollPublishRequest{
Channel: "ns:test", Key: "k", Version: 1, Data: c.data,
})
if c.wantReject {
require.Equal(t, ErrorBadRequest, resp.Error)
} else {
// The test node has no shared poll manager, so a valid payload
// gets past validation and fails later — the point is that it is
// not rejected as a bad request.
require.NotEqual(t, ErrorBadRequest, resp.Error)
}
})
}
}

314
internal/api/consuming.go Normal file
View File

@@ -0,0 +1,314 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// ConsumingHandlerConfig configures ConsumingHandler.
type ConsumingHandlerConfig struct {
UseOpenTelemetry bool
}
// ConsumingHandler is responsible for processing API commands from asynchronous consumers.
type ConsumingHandler struct {
node *centrifuge.Node
config ConsumingHandlerConfig
api *Executor
}
// NewConsumingHandler creates new ConsumingHandler.
func NewConsumingHandler(n *centrifuge.Node, apiExecutor *Executor, c ConsumingHandlerConfig) *ConsumingHandler {
h := &ConsumingHandler{
node: n,
config: c,
api: apiExecutor,
}
return h
}
func logNonRetryableConsumingError(err error, method string) {
log.Error().Err(err).Str("method", method).Msg("non retryable error during consuming, skip message")
}
// ErrShutdown is returned to consumers for messages received after Node shutdown
// started. Such messages must not be acknowledged: this node is no longer able to
// deliver them, and depending on the broker a publish may still return success (a
// memory broker never fails, so without this check a message would be committed
// while going nowhere). Returning an error makes consumers leave the message
// unacknowledged, so it's redelivered to another node or after restart.
//
// It wraps context.Canceled since consumers treat cancellation as a stop signal
// and give up immediately instead of retrying with backoff until the shutdown
// timeout is reached.
var ErrShutdown = fmt.Errorf("node is shutting down: %w", context.Canceled)
type Dispatcher struct {
handler *ConsumingHandler
}
func NewDispatcher(handler *ConsumingHandler) *Dispatcher {
return &Dispatcher{
handler: handler,
}
}
// shuttingDown returns true once Node shutdown started.
func (d *Dispatcher) shuttingDown() bool {
select {
case <-d.handler.node.NotifyShutdown():
return true
default:
return false
}
}
func (d *Dispatcher) Publish(ctx context.Context, req *apiproto.PublishRequest) error {
if d.shuttingDown() {
return ErrShutdown
}
resp := d.handler.api.Publish(ctx, req)
if d.handler.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
return resp.Error
}
if resp.Error != nil {
logNonRetryableConsumingError(resp.Error, "publish")
}
return nil
}
func (d *Dispatcher) Broadcast(ctx context.Context, req *apiproto.BroadcastRequest) error {
if d.shuttingDown() {
return ErrShutdown
}
resp := d.handler.api.Broadcast(ctx, req)
if d.handler.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
return resp.Error
}
if resp.Error != nil {
logNonRetryableConsumingError(resp.Error, "broadcast")
return nil
}
for _, response := range resp.Result.Responses {
if response.Error != nil && response.Error.Code == apiproto.ErrorInternal.Code {
// Any internal error in any channel response will result into a retry by a consumer.
// To prevent duplicate messages publishers may use idempotency keys.
return response.Error
}
}
return nil
}
type ConsumedPublication struct {
Data []byte
// IdempotencyKey is used to prevent duplicate messages.
IdempotencyKey string
// Delta is used to indicate that the message is a delta update.
Delta bool
// Tags are used to attach metadata to the message.
Tags map[string]string
// Version of publication.
Version uint64
// VersionEpoch of publication.
VersionEpoch string
}
func (d *Dispatcher) DispatchPublication(
ctx context.Context, channels []string, pub ConsumedPublication,
) error {
if d.shuttingDown() {
return ErrShutdown
}
if len(channels) == 0 {
return nil
}
if len(channels) == 1 {
req := &apiproto.PublishRequest{
Data: pub.Data,
Channel: channels[0],
IdempotencyKey: pub.IdempotencyKey,
Delta: pub.Delta,
Tags: pub.Tags,
Version: pub.Version,
VersionEpoch: pub.VersionEpoch,
}
return d.Publish(ctx, req)
}
req := &apiproto.BroadcastRequest{
Data: pub.Data,
Channels: channels,
IdempotencyKey: pub.IdempotencyKey,
Delta: pub.Delta,
Tags: pub.Tags,
Version: pub.Version,
VersionEpoch: pub.VersionEpoch,
}
return d.Broadcast(ctx, req)
}
// Dispatch processes commands received from asynchronous consumers.
func (d *Dispatcher) dispatchMethodPayload(ctx context.Context, method string, payload []byte) error {
switch method {
case "publish":
_, err := d.handler.handlePublish(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "broadcast":
// This one is special as we need to iterate over responses. Ideally we want to use
// code gen for Dispatch but need to make sure that broadcast logic is preserved.
res, err := d.handler.handleBroadcast(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
for _, resp := range res.Responses {
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
// Any internal error in any channel response will result into a retry by a consumer.
// To prevent duplicate messages publishers may use idempotency keys.
return resp.Error
}
}
return nil
case "subscribe":
_, err := d.handler.handleSubscribe(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "unsubscribe":
_, err := d.handler.handleUnsubscribe(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "disconnect":
_, err := d.handler.handleDisconnect(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "history_remove":
_, err := d.handler.handleHistoryRemove(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
case "refresh":
_, err := d.handler.handleRefresh(ctx, payload)
if err != nil {
var apiError *apiproto.Error
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
return err
}
logNonRetryableConsumingError(err, method)
return nil
}
return nil
default:
// Skip unsupported.
log.Info().Msg("skip unsupported API command method")
return nil
}
}
var ErrInvalidData = errors.New("invalid data")
// JSONRawOrString can decode payload from bytes and from JSON string. This gives
// us better interoperability. For example, JSONB field is encoded as JSON string in
// Debezium PostgreSQL connector.
type JSONRawOrString json.RawMessage
func (j *JSONRawOrString) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] == '"' {
// Unmarshal as a string, then convert the string to json.RawMessage.
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*j = JSONRawOrString(str)
} else {
// Unmarshal directly as json.RawMessage
*j = data
}
return nil
}
// MarshalJSON returns m as the JSON encoding of m.
func (j JSONRawOrString) MarshalJSON() ([]byte, error) {
if j == nil {
return []byte("null"), nil
}
return j, nil
}
type MethodWithRequestPayload struct {
Method string `json:"method"`
Payload JSONRawOrString `json:"payload"`
}
// DispatchCommand processes commands received from asynchronous consumers.
func (d *Dispatcher) DispatchCommand(ctx context.Context, method string, payload []byte) error {
if d.shuttingDown() {
return ErrShutdown
}
if method != "" {
// If method already known – we can skip decoding into MethodWithRequestPayload.
return d.dispatchMethodPayload(ctx, method, payload)
}
// Otherwise we expect payload to be MethodWithRequestPayload.
var e MethodWithRequestPayload
err := json.Unmarshal(payload, &e)
if err != nil {
log.Error().Err(err).Msg("skip malformed consumed message")
return nil
}
return d.dispatchMethodPayload(ctx, e.Method, e.Payload)
}

View File

@@ -0,0 +1,132 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package api
import (
"context"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func (h *ConsumingHandler) handlePublish(ctx context.Context, data []byte) (*PublishResult, error) {
req, err := requestDecoder.DecodePublish(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Publish(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "publish", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleBroadcast(ctx context.Context, data []byte) (*BroadcastResult, error) {
req, err := requestDecoder.DecodeBroadcast(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Broadcast(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "broadcast", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleSubscribe(ctx context.Context, data []byte) (*SubscribeResult, error) {
req, err := requestDecoder.DecodeSubscribe(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Subscribe(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "subscribe", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleUnsubscribe(ctx context.Context, data []byte) (*UnsubscribeResult, error) {
req, err := requestDecoder.DecodeUnsubscribe(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Unsubscribe(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "unsubscribe", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleDisconnect(ctx context.Context, data []byte) (*DisconnectResult, error) {
req, err := requestDecoder.DecodeDisconnect(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Disconnect(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "disconnect", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleHistoryRemove(ctx context.Context, data []byte) (*HistoryRemoveResult, error) {
req, err := requestDecoder.DecodeHistoryRemove(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.HistoryRemove(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "history_remove", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}
func (h *ConsumingHandler) handleRefresh(ctx context.Context, data []byte) (*RefreshResult, error) {
req, err := requestDecoder.DecodeRefresh(data)
if err != nil {
return nil, ErrInvalidData
}
resp := h.api.Refresh(ctx, req)
if h.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil {
metrics.IncAPIError(h.api.config.Protocol, "refresh", resp.Error.Code)
return nil, resp.Error
}
return resp.Result, nil
}

View File

@@ -0,0 +1,74 @@
package api
import (
"context"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/stretchr/testify/require"
)
func TestNewConsumingHandler(t *testing.T) {
n := nodeWithMemoryEngine()
defer func() { _ = n.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
handler := NewConsumingHandler(n, NewExecutor(n, cfgContainer, nil, ExecutorConfig{
Protocol: "consumer",
UseOpenTelemetry: false,
}), ConsumingHandlerConfig{})
dispatcher := NewDispatcher(handler)
// Bad request must be just logged but no errors other than Internal Error should be returned from Dispatch.
err = dispatcher.DispatchCommand(context.Background(), "publish", []byte(`{}`))
require.NoError(t, err)
}
func TestDispatcherErrorsAfterNodeShutdown(t *testing.T) {
n := nodeWithMemoryEngine()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
dispatcher := NewDispatcher(NewConsumingHandler(n, NewExecutor(n, cfgContainer, nil, ExecutorConfig{
Protocol: "consumer",
}), ConsumingHandlerConfig{}))
const payload = `{"channel": "test", "data": {}}`
// While node is running message must be dispatched successfully.
require.NoError(t, dispatcher.DispatchCommand(context.Background(), "publish", []byte(payload)))
require.NoError(t, n.Shutdown(context.Background()))
// Memory broker keeps accepting publications even after Node shutdown – without
// an explicit check a consumer would acknowledge a message which was never and
// will never be delivered.
err = dispatcher.DispatchCommand(context.Background(), "publish", []byte(payload))
require.ErrorIs(t, err, ErrShutdown)
// Consumers must stop processing instead of retrying until shutdown timeout.
require.ErrorIs(t, err, context.Canceled)
err = dispatcher.DispatchPublication(context.Background(), []string{"test"}, ConsumedPublication{
Data: []byte(`{}`),
})
require.ErrorIs(t, err, ErrShutdown)
// Typed entry points must behave the same way.
err = dispatcher.Publish(context.Background(), &apiproto.PublishRequest{
Channel: "test", Data: []byte(`{}`),
})
require.ErrorIs(t, err, ErrShutdown)
err = dispatcher.Broadcast(context.Background(), &apiproto.BroadcastRequest{
Channels: []string{"test"}, Data: []byte(`{}`),
})
require.ErrorIs(t, err, ErrShutdown)
}

77
internal/api/grpc.go Normal file
View File

@@ -0,0 +1,77 @@
package api
import (
"context"
"crypto/subtle"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifuge"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func authorize(ctx context.Context, key []byte) error {
if md, ok := metadata.FromIncomingContext(ctx); ok {
if len(md["authorization"]) > 0 && subtle.ConstantTimeCompare([]byte(md["authorization"][0]), key) == 1 {
return nil
}
}
return status.Error(codes.Unauthenticated, "unauthenticated")
}
// GRPCKeyAuth allows to set simple authentication based on string key from configuration.
// Client should provide per RPC credentials: set authorization key to metadata with value
// `apikey <KEY>`.
// Only installed when grpc_api.key is set – an unauthenticated GRPC API is the documented
// default in v6, see the note in internal/app/grpc.go before reporting it as a vulnerability.
func GRPCKeyAuth(key string) grpc.ServerOption {
authKey := []byte("apikey " + key)
return grpc.UnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
if err := authorize(ctx, authKey); err != nil {
return nil, err
}
return handler(ctx, req)
})
}
// GRPCAPIServiceConfig for GRPC API Service.
type GRPCAPIServiceConfig struct {
UseOpenTelemetry bool
UseTransportErrorMode bool
}
// RegisterGRPCServerAPI registers GRPC API service in provided GRPC server.
func RegisterGRPCServerAPI(n *centrifuge.Node, apiExecutor *Executor, server *grpc.Server, config GRPCAPIServiceConfig) error {
RegisterCentrifugoApiServer(server, newGRPCAPIService(n, apiExecutor, config))
return nil
}
// grpcAPIService can answer on GRPC API requests.
type grpcAPIService struct {
UnimplementedCentrifugoApiServer
config GRPCAPIServiceConfig
api *Executor
}
// newGRPCAPIService creates new Service.
func newGRPCAPIService(_ *centrifuge.Node, apiExecutor *Executor, c GRPCAPIServiceConfig) *grpcAPIService {
return &grpcAPIService{
config: c,
api: apiExecutor,
}
}
func (s *grpcAPIService) useTransportErrorMode(ctx context.Context) bool {
if s.config.UseTransportErrorMode {
return true
}
if md, ok := metadata.FromIncomingContext(ctx); ok {
errorMode := md.Get("x-centrifugo-error-mode")
return len(errorMode) > 0 && errorMode[0] == "transport"
}
return false
}

View File

@@ -0,0 +1,400 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package api
import (
"context"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/status"
)
// Batch ...
func (s *grpcAPIService) Batch(ctx context.Context, req *BatchRequest) (*BatchResponse, error) {
resp := s.api.Batch(ctx, req)
return resp, nil
}
// Publish ...
func (s *grpcAPIService) Publish(ctx context.Context, req *PublishRequest) (*PublishResponse, error) {
resp := s.api.Publish(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
}
return resp, nil
}
// Broadcast ...
func (s *grpcAPIService) Broadcast(ctx context.Context, req *BroadcastRequest) (*BroadcastResponse, error) {
resp := s.api.Broadcast(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
}
return resp, nil
}
// Subscribe ...
func (s *grpcAPIService) Subscribe(ctx context.Context, req *SubscribeRequest) (*SubscribeResponse, error) {
resp := s.api.Subscribe(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
}
return resp, nil
}
// Unsubscribe ...
func (s *grpcAPIService) Unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*UnsubscribeResponse, error) {
resp := s.api.Unsubscribe(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
}
return resp, nil
}
// Disconnect ...
func (s *grpcAPIService) Disconnect(ctx context.Context, req *DisconnectRequest) (*DisconnectResponse, error) {
resp := s.api.Disconnect(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
}
return resp, nil
}
// Presence ...
func (s *grpcAPIService) Presence(ctx context.Context, req *PresenceRequest) (*PresenceResponse, error) {
resp := s.api.Presence(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
}
return resp, nil
}
// PresenceStats ...
func (s *grpcAPIService) PresenceStats(ctx context.Context, req *PresenceStatsRequest) (*PresenceStatsResponse, error) {
resp := s.api.PresenceStats(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
}
return resp, nil
}
// History ...
func (s *grpcAPIService) History(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error) {
resp := s.api.History(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
}
return resp, nil
}
// HistoryRemove ...
func (s *grpcAPIService) HistoryRemove(ctx context.Context, req *HistoryRemoveRequest) (*HistoryRemoveResponse, error) {
resp := s.api.HistoryRemove(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
}
return resp, nil
}
// Info ...
func (s *grpcAPIService) Info(ctx context.Context, req *InfoRequest) (*InfoResponse, error) {
resp := s.api.Info(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
}
return resp, nil
}
// RPC ...
func (s *grpcAPIService) RPC(ctx context.Context, req *RPCRequest) (*RPCResponse, error) {
resp := s.api.RPC(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
}
return resp, nil
}
// Refresh ...
func (s *grpcAPIService) Refresh(ctx context.Context, req *RefreshRequest) (*RefreshResponse, error) {
resp := s.api.Refresh(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
}
return resp, nil
}
// Channels ...
func (s *grpcAPIService) Channels(ctx context.Context, req *ChannelsRequest) (*ChannelsResponse, error) {
resp := s.api.Channels(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
}
return resp, nil
}
// MapPublish ...
func (s *grpcAPIService) MapPublish(ctx context.Context, req *MapPublishRequest) (*MapPublishResponse, error) {
resp := s.api.MapPublish(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
}
return resp, nil
}
// MapRemove ...
func (s *grpcAPIService) MapRemove(ctx context.Context, req *MapRemoveRequest) (*MapRemoveResponse, error) {
resp := s.api.MapRemove(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
}
return resp, nil
}
// MapReadState ...
func (s *grpcAPIService) MapReadState(ctx context.Context, req *MapReadStateRequest) (*MapReadStateResponse, error) {
resp := s.api.MapReadState(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
}
return resp, nil
}
// MapReadStream ...
func (s *grpcAPIService) MapReadStream(ctx context.Context, req *MapReadStreamRequest) (*MapReadStreamResponse, error) {
resp := s.api.MapReadStream(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
}
return resp, nil
}
// MapStats ...
func (s *grpcAPIService) MapStats(ctx context.Context, req *MapStatsRequest) (*MapStatsResponse, error) {
resp := s.api.MapStats(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
}
return resp, nil
}
// MapClear ...
func (s *grpcAPIService) MapClear(ctx context.Context, req *MapClearRequest) (*MapClearResponse, error) {
resp := s.api.MapClear(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
}
return resp, nil
}
// SharedPollPublish ...
func (s *grpcAPIService) SharedPollPublish(ctx context.Context, req *SharedPollPublishRequest) (*SharedPollPublishResponse, error) {
resp := s.api.SharedPollPublish(ctx, req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(ctx) {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
statusCode := MapErrorToGRPCCode(resp.Error)
transportError, _ := status.New(statusCode, resp.Error.Message).WithDetails(resp.Error)
return nil, transportError.Err()
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
}
return resp, nil
}

35
internal/api/grpc_test.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
)
func TestGRPC_Authorize_Unauthenticated(t *testing.T) {
err := authorize(context.Background(), []byte("apikey xxx"))
require.Error(t, err)
md := metadata.New(map[string]string{})
ctx := metadata.NewIncomingContext(context.Background(), md)
err = authorize(ctx, []byte("apikey xxx"))
require.Error(t, err)
md = metadata.New(map[string]string{
"authorization": "apikey yyy",
})
ctx = metadata.NewIncomingContext(context.Background(), md)
err = authorize(ctx, []byte("apikey xxx"))
require.Error(t, err)
}
func TestGRPC_Authorize_OK(t *testing.T) {
md := metadata.New(map[string]string{
"authorization": "apikey xxx",
})
ctx := metadata.NewIncomingContext(context.Background(), md)
err := authorize(ctx, []byte("apikey xxx"))
require.NoError(t, err)
}

95
internal/api/handler.go Normal file
View File

@@ -0,0 +1,95 @@
package api
import (
"net/http"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
)
// Config configures Handler.
type Config struct {
UseOpenTelemetry bool
UseTransportErrorMode bool
}
// Handler is responsible for processing API commands over HTTP.
type Handler struct {
mux *http.ServeMux
node *centrifuge.Node
config Config
api *Executor
}
var requestDecoder = NewJSONRequestDecoder()
var responseEncoder = NewJSONResponseEncoder()
// NewHandler creates new Handler.
func NewHandler(n *centrifuge.Node, apiExecutor *Executor, c Config) *Handler {
m := new(http.ServeMux)
h := &Handler{
mux: m,
node: n,
config: c,
api: apiExecutor,
}
return h
}
func (s *Handler) Routes() map[string]http.HandlerFunc {
return map[string]http.HandlerFunc{
"/batch": s.handleBatch,
"/publish": s.handlePublish,
"/broadcast": s.handleBroadcast,
"/subscribe": s.handleSubscribe,
"/unsubscribe": s.handleUnsubscribe,
"/disconnect": s.handleDisconnect,
"/presence": s.handlePresence,
"/presence_stats": s.handlePresenceStats,
"/history": s.handleHistory,
"/history_remove": s.handleHistoryRemove,
"/info": s.handleInfo,
"/rpc": s.handleRPC,
"/refresh": s.handleRefresh,
"/channels": s.handleChannels,
"/map_publish": s.handleMapPublish,
"/map_remove": s.handleMapRemove,
"/map_read_state": s.handleMapReadState,
"/map_read_stream": s.handleMapReadStream,
"/map_stats": s.handleMapStats,
"/map_clear": s.handleMapClear,
"/shared_poll_publish": s.handleSharedPollPublish,
}
}
func (s *Handler) handleReadDataError(r *http.Request, w http.ResponseWriter, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("error reading API request body")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
func (s *Handler) handleUnmarshalError(r *http.Request, w http.ResponseWriter, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("error decoding API request body")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
func (s *Handler) handleMarshalError(r *http.Request, w http.ResponseWriter, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("error encoding API response")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
func (s *Handler) writeJson(w http.ResponseWriter, data []byte) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
func (s *Handler) writeJsonCustomStatus(w http.ResponseWriter, statusCode int, data []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, _ = w.Write(data)
}
func (s *Handler) useTransportErrorMode(r *http.Request) bool {
return s.config.UseTransportErrorMode || r.Header.Get("X-Centrifugo-Error-Mode") == "transport"
}

881
internal/api/handler_gen.go Normal file
View File

@@ -0,0 +1,881 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package api
import (
"io"
"net/http"
. "github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func (s *Handler) handleBatch(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "batch", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeBatch(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "batch", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Batch(r.Context(), req)
data, err = responseEncoder.EncodeBatch(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "batch", "marshal")
s.handleMarshalError(r, w, err)
return
}
s.writeJson(w, data)
}
func (s *Handler) handlePublish(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "publish", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodePublish(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "publish", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Publish(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodePublish(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "publish", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "publish", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleBroadcast(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "broadcast", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeBroadcast(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "broadcast", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Broadcast(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeBroadcast(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "broadcast", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "broadcast", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleSubscribe(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "subscribe", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeSubscribe(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "subscribe", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Subscribe(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeSubscribe(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "subscribe", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "subscribe", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "unsubscribe", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeUnsubscribe(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "unsubscribe", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Unsubscribe(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeUnsubscribe(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "unsubscribe", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "unsubscribe", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleDisconnect(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "disconnect", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeDisconnect(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "disconnect", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Disconnect(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeDisconnect(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "disconnect", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "disconnect", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handlePresence(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodePresence(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Presence(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodePresence(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handlePresenceStats(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence_stats", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodePresenceStats(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence_stats", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.PresenceStats(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodePresenceStats(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "presence_stats", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "presence_stats", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleHistory(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeHistory(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.History(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeHistory(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleHistoryRemove(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history_remove", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeHistoryRemove(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history_remove", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.HistoryRemove(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeHistoryRemove(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "history_remove", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "history_remove", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleInfo(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "info", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeInfo(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "info", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Info(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeInfo(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "info", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "info", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleRPC(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "rpc", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeRPC(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "rpc", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.RPC(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeRPC(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "rpc", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "rpc", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "refresh", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeRefresh(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "refresh", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Refresh(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeRefresh(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "refresh", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "refresh", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleChannels(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "channels", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeChannels(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "channels", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.Channels(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeChannels(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "channels", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "channels", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapPublish(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_publish", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapPublish(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_publish", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapPublish(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapPublish(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_publish", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_publish", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapRemove(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_remove", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapRemove(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_remove", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapRemove(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapRemove(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_remove", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_remove", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapReadState(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_state", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapReadState(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_state", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapReadState(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapReadState(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_state", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_state", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapReadStream(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_stream", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapReadStream(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_stream", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapReadStream(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapReadStream(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_read_stream", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_read_stream", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapStats(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_stats", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapStats(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_stats", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapStats(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapStats(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_stats", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_stats", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleMapClear(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_clear", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeMapClear(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_clear", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.MapClear(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeMapClear(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "map_clear", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "map_clear", resp.Error.Code)
}
s.writeJson(w, data)
}
func (s *Handler) handleSharedPollPublish(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "shared_poll_publish", "read_body")
s.handleReadDataError(r, w, err)
return
}
req, err := requestDecoder.DecodeSharedPollPublish(data)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "shared_poll_publish", "unmarshal")
s.handleUnmarshalError(r, w, err)
return
}
resp := s.api.SharedPollPublish(r.Context(), req)
if s.config.UseOpenTelemetry && resp.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, resp.Error.Error())
}
if resp.Error != nil && s.useTransportErrorMode(r) {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
statusCode := MapErrorToHTTPCode(resp.Error)
data, _ = EncodeError(resp.Error)
s.writeJsonCustomStatus(w, statusCode, data)
return
}
data, err = responseEncoder.EncodeSharedPollPublish(resp)
if err != nil {
metrics.IncAPIErrorStringCode(s.api.config.Protocol, "shared_poll_publish", "marshal")
s.handleMarshalError(r, w, err)
return
}
if resp.Error != nil {
metrics.IncAPIError(s.api.config.Protocol, "shared_poll_publish", resp.Error.Code)
}
s.writeJson(w, data)
}

View File

@@ -0,0 +1,774 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
type LegacyCommand struct {
Id uint32 `json:"id,omitempty"`
Method CommandMethodType `json:"method,omitempty"`
Params apiproto.Raw `json:"params,omitempty"`
}
type LegacyReply struct {
Id uint32 `json:"id,omitempty"`
Error *apiproto.Error `json:"error,omitempty"`
Result apiproto.Raw `json:"result,omitempty"`
}
type CommandMethodType int32
// UnmarshalJSON helps to unmarshal command method when set as string.
func (m *CommandMethodType) UnmarshalJSON(data []byte) error {
if len(data) > 2 {
if v, ok := Command_MethodType_value[strings.ToUpper(string(data[1:len(data)-1]))]; ok {
*m = CommandMethodType(v)
return nil
}
}
val, err := strconv.Atoi(string(data))
if err != nil {
return err
}
*m = CommandMethodType(val)
return nil
}
// GetParamsDecoder ...
func GetParamsDecoder() apiproto.RequestDecoder {
return apiproto.NewJSONRequestDecoder()
}
// PutParamsDecoder ...
func PutParamsDecoder(_ apiproto.RequestDecoder) {}
// GetResultEncoder ...
func GetResultEncoder() apiproto.ResultEncoder {
return apiproto.NewJSONResultEncoder()
}
// PutResultEncoder ...
func PutResultEncoder(_ apiproto.ResultEncoder) {}
var (
jsonReplyEncoderPool sync.Pool
)
// ReplyEncoder ...
type ReplyEncoder interface {
Reset()
Encode(reply *LegacyReply) error
Finish() []byte
}
var _ ReplyEncoder = (*JSONReplyEncoder)(nil)
// JSONReplyEncoder ...
type JSONReplyEncoder struct {
count int
buffer bytes.Buffer
}
// NewJSONReplyEncoder ...
func NewJSONReplyEncoder() *JSONReplyEncoder {
return &JSONReplyEncoder{}
}
// Reset ...
func (e *JSONReplyEncoder) Reset() {
e.count = 0
e.buffer.Reset()
}
// Encode ...
func (e *JSONReplyEncoder) Encode(r *LegacyReply) error {
if e.count > 0 {
e.buffer.WriteString("\n")
}
data, err := json.Marshal(r)
if err != nil {
return err
}
e.buffer.Write(data)
e.count++
return nil
}
// Finish ...
func (e *JSONReplyEncoder) Finish() []byte {
data := e.buffer.Bytes()
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
return dataCopy
}
// GetReplyEncoder ...
func GetReplyEncoder() ReplyEncoder {
e := jsonReplyEncoderPool.Get()
if e == nil {
return NewJSONReplyEncoder()
}
return e.(ReplyEncoder)
}
// PutReplyEncoder ...
func PutReplyEncoder(e ReplyEncoder) {
e.Reset()
jsonReplyEncoderPool.Put(e)
}
// CommandDecoder ...
type CommandDecoder interface {
Reset([]byte)
Decode() (*LegacyCommand, error)
}
var jsonCommandDecoderPool sync.Pool
var _ CommandDecoder = (*JSONCommandDecoder)(nil)
// GetCommandDecoder ...
func GetCommandDecoder(data []byte) CommandDecoder {
e := jsonCommandDecoderPool.Get()
if e == nil {
return NewJSONCommandDecoder(data)
}
decoder := e.(CommandDecoder)
decoder.Reset(data)
return decoder
}
// PutCommandDecoder ...
func PutCommandDecoder(d CommandDecoder) {
jsonCommandDecoderPool.Put(d)
}
// JSONCommandDecoder ...
type JSONCommandDecoder struct {
decoder *json.Decoder
}
// NewJSONCommandDecoder ...
func NewJSONCommandDecoder(data []byte) *JSONCommandDecoder {
return &JSONCommandDecoder{
decoder: json.NewDecoder(bytes.NewReader(data)),
}
}
// Reset ...
func (d *JSONCommandDecoder) Reset(data []byte) {
d.decoder = json.NewDecoder(bytes.NewReader(data))
}
// Decode ...
func (d *JSONCommandDecoder) Decode() (*LegacyCommand, error) {
var c LegacyCommand
err := d.decoder.Decode(&c)
if err != nil {
return nil, err
}
return &c, nil
}
// OldRoute handles all methods inside one /api handler.
// The plan is to remove it in Centrifugo v7.
func (s *Handler) OldRoute() http.HandlerFunc {
return s.handleAPI
}
func (s *Handler) handleAPI(w http.ResponseWriter, r *http.Request) {
select {
case <-s.node.NotifyShutdown():
w.WriteHeader(http.StatusServiceUnavailable)
return
default:
}
var data []byte
var err error
data, err = io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Msg("error reading API request body")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(data) == 0 {
log.Error().Err(errors.New("no data in API request")).Msg("bad request")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
encoder := GetReplyEncoder()
defer PutReplyEncoder(encoder)
decoder := GetCommandDecoder(data)
defer PutCommandDecoder(decoder)
for {
command, decodeErr := decoder.Decode()
if decodeErr != nil && decodeErr != io.EOF {
log.Error().Err(decodeErr).Msg("error decoding API data")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if command != nil {
if s.config.UseOpenTelemetry {
span := trace.SpanFromContext(r.Context())
span.SetAttributes(attribute.String("method", Command_MethodType_name[int32(command.Method)]))
}
rep, err := s.handleAPICommand(r.Context(), command)
if err != nil {
if s.config.UseOpenTelemetry {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, err.Error())
}
log.Error().Err(err).Msg("error handling API command")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if s.config.UseOpenTelemetry && rep.Error != nil {
span := trace.SpanFromContext(r.Context())
span.SetStatus(codes.Error, rep.Error.Error())
}
err = encoder.Encode(rep)
if err != nil {
log.Error().Err(err).Msg("error encoding API reply")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
if decodeErr == io.EOF {
break
}
}
s.writeJson(w, encoder.Finish())
}
const (
Command_PUBLISH CommandMethodType = 0
Command_BROADCAST CommandMethodType = 1
Command_UNSUBSCRIBE CommandMethodType = 2
Command_DISCONNECT CommandMethodType = 3
Command_PRESENCE CommandMethodType = 4
Command_PRESENCE_STATS CommandMethodType = 5
Command_HISTORY CommandMethodType = 6
Command_HISTORY_REMOVE CommandMethodType = 7
Command_CHANNELS CommandMethodType = 8
Command_INFO CommandMethodType = 9
Command_RPC CommandMethodType = 10
Command_SUBSCRIBE CommandMethodType = 11
Command_REFRESH CommandMethodType = 12
Command_CONNECTIONS CommandMethodType = 14
Command_UPDATE_USER_STATUS CommandMethodType = 15
Command_GET_USER_STATUS CommandMethodType = 16
Command_DELETE_USER_STATUS CommandMethodType = 17
Command_BLOCK_USER CommandMethodType = 18
Command_UNBLOCK_USER CommandMethodType = 19
Command_REVOKE_TOKEN CommandMethodType = 20
Command_INVALIDATE_USER_TOKENS CommandMethodType = 21
Command_DEVICE_REGISTER CommandMethodType = 22
Command_DEVICE_UPDATE CommandMethodType = 23
Command_DEVICE_REMOVE CommandMethodType = 24
Command_DEVICE_LIST CommandMethodType = 25
Command_DEVICE_TOPIC_LIST CommandMethodType = 26
Command_DEVICE_TOPIC_UPDATE CommandMethodType = 27
Command_USER_TOPIC_LIST CommandMethodType = 28
Command_USER_TOPIC_UPDATE CommandMethodType = 29
Command_SEND_PUSH_NOTIFICATION CommandMethodType = 30
Command_UPDATE_PUSH_STATUS CommandMethodType = 31
Command_CANCEL_PUSH CommandMethodType = 32
Command_MAP_PUBLISH CommandMethodType = 33
Command_MAP_REMOVE CommandMethodType = 34
Command_MAP_READ_STATE CommandMethodType = 35
Command_MAP_READ_STREAM CommandMethodType = 36
Command_MAP_STATS CommandMethodType = 37
Command_MAP_CLEAR CommandMethodType = 38
Command_SHARED_POLL_PUBLISH CommandMethodType = 39
)
var Command_MethodType_value = map[string]int32{
"PUBLISH": 0,
"BROADCAST": 1,
"UNSUBSCRIBE": 2,
"DISCONNECT": 3,
"PRESENCE": 4,
"PRESENCE_STATS": 5,
"HISTORY": 6,
"HISTORY_REMOVE": 7,
"CHANNELS": 8,
"INFO": 9,
"RPC": 10,
"SUBSCRIBE": 11,
"REFRESH": 12,
"CONNECTIONS": 14,
"UPDATE_USER_STATUS": 15,
"GET_USER_STATUS": 16,
"DELETE_USER_STATUS": 17,
"BLOCK_USER": 18,
"UNBLOCK_USER": 19,
"REVOKE_TOKEN": 20,
"INVALIDATE_USER_TOKENS": 21,
"DEVICE_REGISTER": 22,
"DEVICE_UPDATE": 23,
"DEVICE_REMOVE": 24,
"DEVICE_LIST": 25,
"DEVICE_TOPIC_LIST": 26,
"DEVICE_TOPIC_UPDATE": 27,
"USER_TOPIC_LIST": 28,
"USER_TOPIC_UPDATE": 29,
"SEND_PUSH_NOTIFICATION": 30,
"UPDATE_PUSH_STATUS": 31,
"CANCEL_PUSH": 32,
"MAP_PUBLISH": 33,
"MAP_REMOVE": 34,
"MAP_READ_STATE": 35,
"MAP_READ_STREAM": 36,
"MAP_STATS": 37,
"MAP_CLEAR": 38,
"SHARED_POLL_PUBLISH": 39,
}
var (
Command_MethodType_name = map[int32]string{
0: "PUBLISH",
1: "BROADCAST",
2: "UNSUBSCRIBE",
3: "DISCONNECT",
4: "PRESENCE",
5: "PRESENCE_STATS",
6: "HISTORY",
7: "HISTORY_REMOVE",
8: "CHANNELS",
9: "INFO",
10: "RPC",
11: "SUBSCRIBE",
12: "REFRESH",
14: "CONNECTIONS",
15: "UPDATE_USER_STATUS",
16: "GET_USER_STATUS",
17: "DELETE_USER_STATUS",
18: "BLOCK_USER",
19: "UNBLOCK_USER",
20: "REVOKE_TOKEN",
21: "INVALIDATE_USER_TOKENS",
22: "DEVICE_REGISTER",
23: "DEVICE_UPDATE",
24: "DEVICE_REMOVE",
25: "DEVICE_LIST",
26: "DEVICE_TOPIC_LIST",
27: "DEVICE_TOPIC_UPDATE",
28: "USER_TOPIC_LIST",
29: "USER_TOPIC_UPDATE",
30: "SEND_PUSH_NOTIFICATION",
31: "UPDATE_PUSH_STATUS",
32: "CANCEL_PUSH",
33: "MAP_PUBLISH",
34: "MAP_REMOVE",
35: "MAP_READ_STATE",
36: "MAP_READ_STREAM",
37: "MAP_STATS",
38: "MAP_CLEAR",
39: "SHARED_POLL_PUBLISH",
}
)
func (s *Handler) handleAPICommand(ctx context.Context, cmd *LegacyCommand) (*LegacyReply, error) {
method := cmd.Method
params := cmd.Params
rep := &LegacyReply{
Id: cmd.Id,
}
var replyRes apiproto.Raw
decoder := GetParamsDecoder()
defer PutParamsDecoder(decoder)
encoder := GetResultEncoder()
defer PutResultEncoder(encoder)
switch method {
case Command_PUBLISH:
cmd, err := decoder.DecodePublish(params)
if err != nil {
log.Error().Err(err).Msg("error decoding publish params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Publish(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodePublish(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_BROADCAST:
cmd, err := decoder.DecodeBroadcast(params)
if err != nil {
log.Error().Err(err).Msg("error decoding broadcast params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Broadcast(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeBroadcast(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_SUBSCRIBE:
cmd, err := decoder.DecodeSubscribe(params)
if err != nil {
log.Error().Err(err).Msg("error decoding subscribe params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Subscribe(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeSubscribe(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_UNSUBSCRIBE:
cmd, err := decoder.DecodeUnsubscribe(params)
if err != nil {
log.Error().Err(err).Msg("error decoding unsubscribe params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Unsubscribe(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeUnsubscribe(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_DISCONNECT:
cmd, err := decoder.DecodeDisconnect(params)
if err != nil {
log.Error().Err(err).Msg("error decoding disconnect params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Disconnect(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeDisconnect(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_PRESENCE:
cmd, err := decoder.DecodePresence(params)
if err != nil {
log.Error().Err(err).Msg("error decoding presence params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Presence(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodePresence(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_PRESENCE_STATS:
cmd, err := decoder.DecodePresenceStats(params)
if err != nil {
log.Error().Err(err).Msg("error decoding presence stats params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.PresenceStats(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodePresenceStats(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_HISTORY:
cmd, err := decoder.DecodeHistory(params)
if err != nil {
log.Error().Err(err).Msg("error decoding history params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.History(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeHistory(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_HISTORY_REMOVE:
cmd, err := decoder.DecodeHistoryRemove(params)
if err != nil {
log.Error().Err(err).Msg("error decoding history remove params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.HistoryRemove(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeHistoryRemove(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_INFO:
resp := s.api.Info(ctx, &apiproto.InfoRequest{})
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
var err error
replyRes, err = encoder.EncodeInfo(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_RPC:
cmd, err := decoder.DecodeRPC(params)
if err != nil {
log.Error().Err(err).Msg("error decoding rpc params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.RPC(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeRPC(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_REFRESH:
cmd, err := decoder.DecodeRefresh(params)
if err != nil {
log.Error().Err(err).Msg("error decoding refresh params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Refresh(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeRefresh(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_CHANNELS:
cmd, err := decoder.DecodeChannels(params)
if err != nil {
log.Error().Err(err).Msg("error decoding channels params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.Channels(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeChannels(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_PUBLISH:
cmd, err := decoder.DecodeMapPublish(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map publish params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapPublish(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapPublish(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_REMOVE:
cmd, err := decoder.DecodeMapRemove(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map remove params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapRemove(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapRemove(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_READ_STATE:
cmd, err := decoder.DecodeMapReadState(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map read state params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapReadState(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapReadState(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_READ_STREAM:
cmd, err := decoder.DecodeMapReadStream(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map read stream params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapReadStream(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapReadStream(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_STATS:
cmd, err := decoder.DecodeMapStats(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map stats params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapStats(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapStats(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_MAP_CLEAR:
cmd, err := decoder.DecodeMapClear(params)
if err != nil {
log.Error().Err(err).Msg("error decoding map clear params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.MapClear(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeMapClear(resp.Result)
if err != nil {
return nil, err
}
}
}
case Command_SHARED_POLL_PUBLISH:
cmd, err := decoder.DecodeSharedPollPublish(params)
if err != nil {
log.Error().Err(err).Msg("error decoding shared poll publish params")
rep.Error = apiproto.ErrorBadRequest
return rep, nil
}
resp := s.api.SharedPollPublish(ctx, cmd)
if resp.Error != nil {
rep.Error = resp.Error
} else {
if resp.Result != nil {
replyRes, err = encoder.EncodeSharedPollPublish(resp.Result)
if err != nil {
return nil, err
}
}
}
default:
rep.Error = apiproto.ErrorNotFound
}
if replyRes != nil {
rep.Result = replyRes
}
return rep, nil
}

View File

@@ -0,0 +1,106 @@
package api
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/stretchr/testify/require"
)
func TestAPIHandler(t *testing.T) {
n := nodeWithMemoryEngine()
defer func() { _ = n.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfgContainer, err := config.NewContainer(cfg)
require.NoError(t, err)
apiExecutor := NewExecutor(n, cfgContainer, &testSurveyCaller{}, ExecutorConfig{Protocol: "test", UseOpenTelemetry: false})
mux := http.NewServeMux()
apiHandler := NewHandler(n, apiExecutor, Config{})
mux.Handle("/api", apiHandler.OldRoute())
for path, handler := range apiHandler.Routes() {
handlePath := "/api" + path
mux.Handle(handlePath, handler)
}
server := httptest.NewServer(mux)
defer server.Close()
// nil body.
req, _ := http.NewRequest("POST", server.URL+"/api", nil)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
_ = resp.Body.Close()
// empty body.
req, _ = http.NewRequest("POST", server.URL+"/api", strings.NewReader(""))
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
_ = resp.Body.Close()
// valid JSON request.
data := `{"method":"publish","params":{"channel": "test", "data":{}}}`
req, _ = http.NewRequest("POST", server.URL+"/api", bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusOK)
_ = resp.Body.Close()
// valid JSON request to special method route
data = `{"channel": "test", "data":{}}`
req, _ = http.NewRequest("POST", server.URL+"/api/publish", bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusOK)
_ = resp.Body.Close()
// request with unknown method.
data = `{"method":"unknown","params":{"channel": "test", "data":{}}}`
req, _ = http.NewRequest("POST", server.URL+"/api", bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
_ = resp.Body.Close()
}
func BenchmarkAPIHandler(b *testing.B) {
n := nodeWithMemoryEngine()
defer func() { _ = n.Shutdown(context.Background()) }()
cfg := config.DefaultConfig()
cfg.Client.Insecure = true
cfgContainer, err := config.NewContainer(cfg)
require.NoError(b, err)
handler := NewHandler(n, NewExecutor(n, cfgContainer, nil, ExecutorConfig{
Protocol: "http",
UseOpenTelemetry: false,
}), Config{})
payload := []byte(`{"method": "publish", "params": {"channel": "index", "data": 1}}`)
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
request, _ := http.NewRequest(http.MethodPost, "/api", bytes.NewReader(payload))
recorder := httptest.NewRecorder()
handler.handlePublish(recorder, request)
if recorder.Code != http.StatusOK {
b.Fatalf("unexpected status code %d", recorder.Code)
}
}
})
}

11383
internal/apiproto/api.pb.go Normal file

File diff suppressed because it is too large Load Diff

1070
internal/apiproto/api.proto Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
package apiproto
// RequestDecoder ...
type RequestDecoder interface {
DecodeBatch([]byte) (*BatchRequest, error)
DecodePublish([]byte) (*PublishRequest, error)
DecodeBroadcast([]byte) (*BroadcastRequest, error)
DecodeSubscribe([]byte) (*SubscribeRequest, error)
DecodeUnsubscribe([]byte) (*UnsubscribeRequest, error)
DecodeDisconnect([]byte) (*DisconnectRequest, error)
DecodePresence([]byte) (*PresenceRequest, error)
DecodePresenceStats([]byte) (*PresenceStatsRequest, error)
DecodeHistory([]byte) (*HistoryRequest, error)
DecodeHistoryRemove([]byte) (*HistoryRemoveRequest, error)
DecodeInfo([]byte) (*InfoRequest, error)
DecodeRPC([]byte) (*RPCRequest, error)
DecodeRefresh([]byte) (*RefreshRequest, error)
DecodeChannels([]byte) (*ChannelsRequest, error)
DecodeMapPublish([]byte) (*MapPublishRequest, error)
DecodeMapRemove([]byte) (*MapRemoveRequest, error)
DecodeMapReadState([]byte) (*MapReadStateRequest, error)
DecodeMapReadStream([]byte) (*MapReadStreamRequest, error)
DecodeMapStats([]byte) (*MapStatsRequest, error)
DecodeMapClear([]byte) (*MapClearRequest, error)
DecodeSharedPollPublish([]byte) (*SharedPollPublishRequest, error)
}

View File

@@ -0,0 +1,225 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package apiproto
import "encoding/json"
var _ RequestDecoder = (*JSONRequestDecoder)(nil)
// JSONRequestDecoder ...
type JSONRequestDecoder struct{}
// NewJSONRequestDecoder ...
func NewJSONRequestDecoder() *JSONRequestDecoder {
return &JSONRequestDecoder{}
}
// DecodeBatch ...
func (d *JSONRequestDecoder) DecodeBatch(data []byte) (*BatchRequest, error) {
var p BatchRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodePublish ...
func (d *JSONRequestDecoder) DecodePublish(data []byte) (*PublishRequest, error) {
var p PublishRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeBroadcast ...
func (d *JSONRequestDecoder) DecodeBroadcast(data []byte) (*BroadcastRequest, error) {
var p BroadcastRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeSubscribe ...
func (d *JSONRequestDecoder) DecodeSubscribe(data []byte) (*SubscribeRequest, error) {
var p SubscribeRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeUnsubscribe ...
func (d *JSONRequestDecoder) DecodeUnsubscribe(data []byte) (*UnsubscribeRequest, error) {
var p UnsubscribeRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeDisconnect ...
func (d *JSONRequestDecoder) DecodeDisconnect(data []byte) (*DisconnectRequest, error) {
var p DisconnectRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodePresence ...
func (d *JSONRequestDecoder) DecodePresence(data []byte) (*PresenceRequest, error) {
var p PresenceRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodePresenceStats ...
func (d *JSONRequestDecoder) DecodePresenceStats(data []byte) (*PresenceStatsRequest, error) {
var p PresenceStatsRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeHistory ...
func (d *JSONRequestDecoder) DecodeHistory(data []byte) (*HistoryRequest, error) {
var p HistoryRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeHistoryRemove ...
func (d *JSONRequestDecoder) DecodeHistoryRemove(data []byte) (*HistoryRemoveRequest, error) {
var p HistoryRemoveRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeInfo ...
func (d *JSONRequestDecoder) DecodeInfo(data []byte) (*InfoRequest, error) {
var p InfoRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeRPC ...
func (d *JSONRequestDecoder) DecodeRPC(data []byte) (*RPCRequest, error) {
var p RPCRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeRefresh ...
func (d *JSONRequestDecoder) DecodeRefresh(data []byte) (*RefreshRequest, error) {
var p RefreshRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeChannels ...
func (d *JSONRequestDecoder) DecodeChannels(data []byte) (*ChannelsRequest, error) {
var p ChannelsRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapPublish ...
func (d *JSONRequestDecoder) DecodeMapPublish(data []byte) (*MapPublishRequest, error) {
var p MapPublishRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapRemove ...
func (d *JSONRequestDecoder) DecodeMapRemove(data []byte) (*MapRemoveRequest, error) {
var p MapRemoveRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapReadState ...
func (d *JSONRequestDecoder) DecodeMapReadState(data []byte) (*MapReadStateRequest, error) {
var p MapReadStateRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapReadStream ...
func (d *JSONRequestDecoder) DecodeMapReadStream(data []byte) (*MapReadStreamRequest, error) {
var p MapReadStreamRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapStats ...
func (d *JSONRequestDecoder) DecodeMapStats(data []byte) (*MapStatsRequest, error) {
var p MapStatsRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeMapClear ...
func (d *JSONRequestDecoder) DecodeMapClear(data []byte) (*MapClearRequest, error) {
var p MapClearRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// DecodeSharedPollPublish ...
func (d *JSONRequestDecoder) DecodeSharedPollPublish(data []byte) (*SharedPollPublishRequest, error) {
var p SharedPollPublishRequest
err := json.Unmarshal(data, &p)
if err != nil {
return nil, err
}
return &p, nil
}

View File

@@ -0,0 +1,25 @@
package apiproto
// ResultEncoder ...
type ResultEncoder interface {
EncodePublish(*PublishResult) ([]byte, error)
EncodeBroadcast(*BroadcastResult) ([]byte, error)
EncodeSubscribe(*SubscribeResult) ([]byte, error)
EncodeUnsubscribe(*UnsubscribeResult) ([]byte, error)
EncodeDisconnect(*DisconnectResult) ([]byte, error)
EncodePresence(*PresenceResult) ([]byte, error)
EncodePresenceStats(*PresenceStatsResult) ([]byte, error)
EncodeHistory(*HistoryResult) ([]byte, error)
EncodeHistoryRemove(*HistoryRemoveResult) ([]byte, error)
EncodeInfo(*InfoResult) ([]byte, error)
EncodeRPC(*RPCResult) ([]byte, error)
EncodeRefresh(*RefreshResult) ([]byte, error)
EncodeChannels(*ChannelsResult) ([]byte, error)
EncodeMapPublish(*MapPublishResult) ([]byte, error)
EncodeMapRemove(*MapRemoveResult) ([]byte, error)
EncodeMapReadState(*MapReadStateResult) ([]byte, error)
EncodeMapReadStream(*MapReadStreamResult) ([]byte, error)
EncodeMapStats(*MapStatsResult) ([]byte, error)
EncodeMapClear(*MapClearResult) ([]byte, error)
EncodeSharedPollPublish(*SharedPollPublishResult) ([]byte, error)
}

View File

@@ -0,0 +1,7 @@
package apiproto
import "encoding/json"
func EncodeError(err *Error) ([]byte, error) {
return json.Marshal(err)
}

View File

@@ -0,0 +1,117 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package apiproto
import "encoding/json"
// JSONResponseEncoder ...
type JSONResponseEncoder struct{}
func NewJSONResponseEncoder() *JSONResponseEncoder {
return &JSONResponseEncoder{}
}
// EncodeBatch ...
func (e *JSONResponseEncoder) EncodeBatch(response *BatchResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodePublish ...
func (e *JSONResponseEncoder) EncodePublish(response *PublishResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeBroadcast ...
func (e *JSONResponseEncoder) EncodeBroadcast(response *BroadcastResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeSubscribe ...
func (e *JSONResponseEncoder) EncodeSubscribe(response *SubscribeResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeUnsubscribe ...
func (e *JSONResponseEncoder) EncodeUnsubscribe(response *UnsubscribeResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeDisconnect ...
func (e *JSONResponseEncoder) EncodeDisconnect(response *DisconnectResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodePresence ...
func (e *JSONResponseEncoder) EncodePresence(response *PresenceResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodePresenceStats ...
func (e *JSONResponseEncoder) EncodePresenceStats(response *PresenceStatsResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeHistory ...
func (e *JSONResponseEncoder) EncodeHistory(response *HistoryResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeHistoryRemove ...
func (e *JSONResponseEncoder) EncodeHistoryRemove(response *HistoryRemoveResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeInfo ...
func (e *JSONResponseEncoder) EncodeInfo(response *InfoResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeRPC ...
func (e *JSONResponseEncoder) EncodeRPC(response *RPCResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeRefresh ...
func (e *JSONResponseEncoder) EncodeRefresh(response *RefreshResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeChannels ...
func (e *JSONResponseEncoder) EncodeChannels(response *ChannelsResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapPublish ...
func (e *JSONResponseEncoder) EncodeMapPublish(response *MapPublishResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapRemove ...
func (e *JSONResponseEncoder) EncodeMapRemove(response *MapRemoveResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapReadState ...
func (e *JSONResponseEncoder) EncodeMapReadState(response *MapReadStateResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapReadStream ...
func (e *JSONResponseEncoder) EncodeMapReadStream(response *MapReadStreamResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapStats ...
func (e *JSONResponseEncoder) EncodeMapStats(response *MapStatsResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeMapClear ...
func (e *JSONResponseEncoder) EncodeMapClear(response *MapClearResponse) ([]byte, error) {
return json.Marshal(response)
}
// EncodeSharedPollPublish ...
func (e *JSONResponseEncoder) EncodeSharedPollPublish(response *SharedPollPublishResponse) ([]byte, error) {
return json.Marshal(response)
}

View File

@@ -0,0 +1,115 @@
// Code generated by internal/gen/api/main.go. DO NOT EDIT.
package apiproto
import "encoding/json"
var _ ResultEncoder = (*JSONResultEncoder)(nil)
// JSONResultEncoder ...
type JSONResultEncoder struct{}
// NewJSONResultEncoder ...
func NewJSONResultEncoder() *JSONResultEncoder {
return &JSONResultEncoder{}
}
// EncodePublish ...
func (e *JSONResultEncoder) EncodePublish(res *PublishResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeBroadcast ...
func (e *JSONResultEncoder) EncodeBroadcast(res *BroadcastResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeSubscribe ...
func (e *JSONResultEncoder) EncodeSubscribe(res *SubscribeResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeUnsubscribe ...
func (e *JSONResultEncoder) EncodeUnsubscribe(res *UnsubscribeResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeDisconnect ...
func (e *JSONResultEncoder) EncodeDisconnect(res *DisconnectResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodePresence ...
func (e *JSONResultEncoder) EncodePresence(res *PresenceResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodePresenceStats ...
func (e *JSONResultEncoder) EncodePresenceStats(res *PresenceStatsResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeHistory ...
func (e *JSONResultEncoder) EncodeHistory(res *HistoryResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeHistoryRemove ...
func (e *JSONResultEncoder) EncodeHistoryRemove(res *HistoryRemoveResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeInfo ...
func (e *JSONResultEncoder) EncodeInfo(res *InfoResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeRPC ...
func (e *JSONResultEncoder) EncodeRPC(res *RPCResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeRefresh ...
func (e *JSONResultEncoder) EncodeRefresh(res *RefreshResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeChannels ...
func (e *JSONResultEncoder) EncodeChannels(res *ChannelsResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapPublish ...
func (e *JSONResultEncoder) EncodeMapPublish(res *MapPublishResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapRemove ...
func (e *JSONResultEncoder) EncodeMapRemove(res *MapRemoveResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapReadState ...
func (e *JSONResultEncoder) EncodeMapReadState(res *MapReadStateResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapReadStream ...
func (e *JSONResultEncoder) EncodeMapReadStream(res *MapReadStreamResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapStats ...
func (e *JSONResultEncoder) EncodeMapStats(res *MapStatsResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeMapClear ...
func (e *JSONResultEncoder) EncodeMapClear(res *MapClearResult) ([]byte, error) {
return json.Marshal(res)
}
// EncodeSharedPollPublish ...
func (e *JSONResultEncoder) EncodeSharedPollPublish(res *SharedPollPublishResult) ([]byte, error) {
return json.Marshal(res)
}

View File

@@ -0,0 +1,93 @@
package apiproto
import (
"fmt"
"net/http"
"google.golang.org/grpc/codes"
)
func (x *Error) Error() string {
return fmt.Sprintf("%d: %s", x.Code, x.Message)
}
// Here we define errors that can be exposed in server API replies.
var (
// ErrorInternal means server error, if returned this is a signal
// that something went wrong with Centrifugo itself.
ErrorInternal = &Error{
Code: 100,
Message: "internal server error",
}
// ErrorUnknownChannel means that namespace in channel name does not exist.
ErrorUnknownChannel = &Error{
Code: 102,
Message: "unknown channel",
}
// ErrorNotFound means that method sent in command does not exist.
ErrorNotFound = &Error{
Code: 104,
Message: "not found",
}
// ErrorBadRequest says that Centrifugo can not parse received data
// because it is malformed.
ErrorBadRequest = &Error{
Code: 107,
Message: "bad request",
}
// ErrorNotAvailable means that resource is not enabled.
ErrorNotAvailable = &Error{
Code: 108,
Message: "not available",
}
// ErrorUnrecoverablePosition means that stream does not contain required
// range of publications to fulfill a history query. This can be happen to
// expiration, size limitation or due to wrong epoch.
ErrorUnrecoverablePosition = &Error{
Code: 112,
Message: "unrecoverable position",
}
// ErrorConflict ...
ErrorConflict = &Error{
Code: 113,
Message: "conflict",
}
)
func MapErrorToHTTPCode(err *Error) int {
switch err.Code {
case ErrorInternal.Code:
return http.StatusInternalServerError
case ErrorUnknownChannel.Code, ErrorNotFound.Code:
return http.StatusNotFound
case ErrorBadRequest.Code, ErrorNotAvailable.Code:
return http.StatusBadRequest
case ErrorUnrecoverablePosition.Code:
return http.StatusRequestedRangeNotSatisfiable
case ErrorConflict.Code:
return http.StatusConflict
default:
// Default to Internal Server Error for unmapped errors.
// In general should be avoided - all new API errors must be explicitly described here.
return http.StatusInternalServerError
}
}
func MapErrorToGRPCCode(err *Error) codes.Code {
switch err.Code {
case ErrorInternal.Code:
return codes.Internal
case ErrorUnknownChannel.Code, ErrorNotFound.Code:
return codes.NotFound
case ErrorBadRequest.Code, ErrorNotAvailable.Code:
return codes.InvalidArgument
case ErrorUnrecoverablePosition.Code:
return codes.OutOfRange
case ErrorConflict.Code:
return codes.AlreadyExists
default:
// Default to Internal Error for unmapped errors.
// In general should be avoided - all new API errors must be explicitly described here.
return codes.Internal
}
}

36
internal/apiproto/generate.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -e
set -o pipefail
# Optional: Uncomment if you want commands printed as they run
# set -x
# brew install protobuf
# go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# go install github.com/fatih/gomodifytags@v1.13.0
# go install github.com/FZambia/gomodifytype@latest
# List of required tools
REQUIRED_TOOLS=("protoc" "protoc-gen-go" "protoc-gen-go-grpc" "gomodifytype" "gomodifytags")
# Check that each tool exists in PATH
for tool in "${REQUIRED_TOOLS[@]}"; do
if ! command -v "$tool" &>/dev/null; then
echo "Error: '$tool' not found in PATH. Please install it before running this script."
exit 1
fi
done
echo "Generating Go code from proto..."
protoc -I ./ \
api.proto \
--go_out=. \
--go-grpc_out=.
echo "Modifying types in generated Go code..."
gomodifytype -file api.pb.go -all -w -from "[]byte" -to "Raw"
echo "Generating tags..."
bash generate_tags.sh

View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
echo "replacing tags of structs..."
gomodifytags -file api.pb.go -field User -struct ClientInfo -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Client -struct ClientInfo -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Presence -struct PresenceResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumClients -struct PresenceStatsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumUsers -struct PresenceStatsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Offset -struct HistoryResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Epoch -struct HistoryResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Publications -struct HistoryResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Uid -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Name -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Version -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumClients -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumUsers -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumChannels -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field NumSubs -struct NodeResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Channels -struct ChannelsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Connections -struct ConnectionsResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Statuses -struct GetUserStatusResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Items -struct DeviceListResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Items -struct DeviceTopicListResult -all -w -remove-options json=omitempty >/dev/null
gomodifytags -file api.pb.go -field Items -struct UserTopicListResult -all -w -remove-options json=omitempty >/dev/null

28
internal/apiproto/raw.go Normal file
View File

@@ -0,0 +1,28 @@
package apiproto
import (
"errors"
)
// Raw type used by Centrifugo as type for fields in structs which value
// we want to left untouched. For example custom application specific JSON
// payload data in published message. This is very similar to json.RawMessage
// type.
type Raw []byte
// MarshalJSON returns *r as the JSON encoding of r.
func (r Raw) MarshalJSON() ([]byte, error) {
if r == nil {
return []byte("null"), nil
}
return r, nil
}
// UnmarshalJSON sets *r to a copy of data.
func (r *Raw) UnmarshalJSON(data []byte) error {
if r == nil {
return errors.New("raw: UnmarshalJSON on nil pointer")
}
*r = append((*r)[0:0], data...)
return nil
}

View File

@@ -0,0 +1,35 @@
package apiproto
import (
"encoding/json"
"testing"
)
type DataRawMessage struct {
Data *json.RawMessage
}
type DataRaw struct {
Data *Raw
}
func TestRaw(t *testing.T) {
data1 := json.RawMessage(`{"key": "value"}`)
stdJsonData1, err := json.Marshal(DataRawMessage{
Data: &data1,
})
if err != nil {
t.Fatalf("%v", err)
}
data2 := Raw(`{"key": "value"}`)
stdJsonData2, err := json.Marshal(DataRaw{
Data: &data2,
})
if err != nil {
t.Fatalf("%v", err)
}
if string(stdJsonData1) != string(stdJsonData2) {
t.Fatalf("no match: %v", err)
}
}

View File

@@ -0,0 +1 @@
Centrifugo server API Protobuf definitions.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
#!/bin/bash
set -e
#go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
protoc --openapiv2_out=disable_default_errors=true,disable_service_tags=true,openapi_naming_strategy=simple,json_names_for_fields=false,allow_merge=true,merge_file_name=api:. ./api.swagger.proto
search='(:\s+{\s+"type":\s)"string",\s+"format":\s"byte"'
replace='\1"object"'
python3 - << EOF
import re
#defining the replace method
def replace(filePath, text, subs, flags=0):
with open(file_path, "r+") as file:
#read the file contents
file_contents = file.read()
text_pattern = re.compile(text, flags)
file_contents = text_pattern.sub(subs, file_contents)
file.seek(0)
file.truncate()
file.write(file_contents)
file_path="api.swagger.json"
text=r'$search'
subs=r'$replace'
#calling the replace method
replace(file_path, text, subs, flags=re.MULTILINE | re.DOTALL)
EOF
search='(:\s+{\s+"type":\s)"string",\s+"format":\s"uint64"'
replace='\1"integer"'
python3 - << EOF
import re
#defining the replace method
def replace(filePath, text, subs, flags=0):
with open(file_path, "r+") as file:
#read the file contents
file_contents = file.read()
text_pattern = re.compile(text, flags)
file_contents = text_pattern.sub(subs, file_contents)
file.seek(0)
file.truncate()
file.write(file_contents)
file_path="api.swagger.json"
text=r'$search'
subs=r'$replace'
#calling the replace method
replace(file_path, text, subs, flags=re.MULTILINE | re.DOTALL)
EOF
search='(:\s+{\s+"type":\s)"string",\s+"format":\s"int64"'
replace='\1"integer"'
python3 - << EOF
import re
#defining the replace method
def replace(filePath, text, subs, flags=0):
with open(file_path, "r+") as file:
#read the file contents
file_contents = file.read()
text_pattern = re.compile(text, flags)
file_contents = text_pattern.sub(subs, file_contents)
file.seek(0)
file.truncate()
file.write(file_contents)
file_path="api.swagger.json"
text=r'$search'
subs=r'$replace'
#calling the replace method
replace(file_path, text, subs, flags=re.MULTILINE | re.DOTALL)
EOF

View File

@@ -0,0 +1,31 @@
// Copyright 2015 Google LLC
//
// Licensed 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.
syntax = "proto3";
package google.api;
import "google/api/http.proto";
import "google/protobuf/descriptor.proto";
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "AnnotationsProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
extend google.protobuf.MethodOptions {
// See `HttpRule`.
HttpRule http = 72295728;
}

View File

@@ -0,0 +1,375 @@
// Copyright 2015 Google LLC
//
// Licensed 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.
syntax = "proto3";
package google.api;
option cc_enable_arenas = true;
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "HttpProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
// Defines the HTTP configuration for an API service. It contains a list of
// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method
// to one or more HTTP REST API methods.
message Http {
// A list of HTTP configuration rules that apply to individual API methods.
//
// **NOTE:** All service configuration rules follow "last one wins" order.
repeated HttpRule rules = 1;
// When set to true, URL path parameters will be fully URI-decoded except in
// cases of single segment matches in reserved expansion, where "%2F" will be
// left encoded.
//
// The default behavior is to not decode RFC 6570 reserved characters in multi
// segment matches.
bool fully_decode_reserved_expansion = 2;
}
// # gRPC Transcoding
//
// gRPC Transcoding is a feature for mapping between a gRPC method and one or
// more HTTP REST endpoints. It allows developers to build a single API service
// that supports both gRPC APIs and REST APIs. Many systems, including [Google
// APIs](https://github.com/googleapis/googleapis),
// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC
// Gateway](https://github.com/grpc-ecosystem/grpc-gateway),
// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature
// and use it for large scale production services.
//
// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies
// how different portions of the gRPC request message are mapped to the URL
// path, URL query parameters, and HTTP request body. It also controls how the
// gRPC response message is mapped to the HTTP response body. `HttpRule` is
// typically specified as an `google.api.http` annotation on the gRPC method.
//
// Each mapping specifies a URL path template and an HTTP method. The path
// template may refer to one or more fields in the gRPC request message, as long
// as each field is a non-repeated field with a primitive (non-message) type.
// The path template controls how fields of the request message are mapped to
// the URL path.
//
// Example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get: "/v1/{name=messages/*}"
// };
// }
// }
// message GetMessageRequest {
// string name = 1; // Mapped to URL path.
// }
// message Message {
// string text = 1; // The resource content.
// }
//
// This enables an HTTP REST to gRPC mapping as below:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")`
//
// Any fields in the request message which are not bound by the path template
// automatically become HTTP query parameters if there is no HTTP request body.
// For example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get:"/v1/messages/{message_id}"
// };
// }
// }
// message GetMessageRequest {
// message SubMessage {
// string subfield = 1;
// }
// string message_id = 1; // Mapped to URL path.
// int64 revision = 2; // Mapped to URL query parameter `revision`.
// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`.
// }
//
// This enables a HTTP JSON to RPC mapping as below:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456?revision=2&sub.subfield=foo` |
// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield:
// "foo"))`
//
// Note that fields which are mapped to URL query parameters must have a
// primitive type or a repeated primitive type or a non-repeated message type.
// In the case of a repeated type, the parameter can be repeated in the URL
// as `...?param=A&param=B`. In the case of a message type, each field of the
// message is mapped to a separate parameter, such as
// `...?foo.a=A&foo.b=B&foo.c=C`.
//
// For HTTP methods that allow a request body, the `body` field
// specifies the mapping. Consider a REST update method on the
// message resource collection:
//
// service Messaging {
// rpc UpdateMessage(UpdateMessageRequest) returns (Message) {
// option (google.api.http) = {
// patch: "/v1/messages/{message_id}"
// body: "message"
// };
// }
// }
// message UpdateMessageRequest {
// string message_id = 1; // mapped to the URL
// Message message = 2; // mapped to the body
// }
//
// The following HTTP JSON to RPC mapping is enabled, where the
// representation of the JSON in the request body is determined by
// protos JSON encoding:
//
// HTTP | gRPC
// -----|-----
// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
// "123456" message { text: "Hi!" })`
//
// The special name `*` can be used in the body mapping to define that
// every field not bound by the path template should be mapped to the
// request body. This enables the following alternative definition of
// the update method:
//
// service Messaging {
// rpc UpdateMessage(Message) returns (Message) {
// option (google.api.http) = {
// patch: "/v1/messages/{message_id}"
// body: "*"
// };
// }
// }
// message Message {
// string message_id = 1;
// string text = 2;
// }
//
//
// The following HTTP JSON to RPC mapping is enabled:
//
// HTTP | gRPC
// -----|-----
// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
// "123456" text: "Hi!")`
//
// Note that when using `*` in the body mapping, it is not possible to
// have HTTP parameters, as all fields not bound by the path end in
// the body. This makes this option more rarely used in practice when
// defining REST APIs. The common usage of `*` is in custom methods
// which don't use the URL at all for transferring data.
//
// It is possible to define multiple HTTP methods for one RPC by using
// the `additional_bindings` option. Example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get: "/v1/messages/{message_id}"
// additional_bindings {
// get: "/v1/users/{user_id}/messages/{message_id}"
// }
// };
// }
// }
// message GetMessageRequest {
// string message_id = 1;
// string user_id = 2;
// }
//
// This enables the following two alternative HTTP JSON to RPC mappings:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")`
// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id:
// "123456")`
//
// ## Rules for HTTP mapping
//
// 1. Leaf request fields (recursive expansion nested messages in the request
// message) are classified into three categories:
// - Fields referred by the path template. They are passed via the URL path.
// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP
// request body.
// - All other fields are passed via the URL query parameters, and the
// parameter name is the field path in the request message. A repeated
// field can be represented as multiple query parameters under the same
// name.
// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields
// are passed via URL path and HTTP request body.
// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all
// fields are passed via URL path and URL query parameters.
//
// ### Path template syntax
//
// Template = "/" Segments [ Verb ] ;
// Segments = Segment { "/" Segment } ;
// Segment = "*" | "**" | LITERAL | Variable ;
// Variable = "{" FieldPath [ "=" Segments ] "}" ;
// FieldPath = IDENT { "." IDENT } ;
// Verb = ":" LITERAL ;
//
// The syntax `*` matches a single URL path segment. The syntax `**` matches
// zero or more URL path segments, which must be the last part of the URL path
// except the `Verb`.
//
// The syntax `Variable` matches part of the URL path as specified by its
// template. A variable template must not contain other variables. If a variable
// matches a single path segment, its template may be omitted, e.g. `{var}`
// is equivalent to `{var=*}`.
//
// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL`
// contains any reserved character, such characters should be percent-encoded
// before the matching.
//
// If a variable contains exactly one path segment, such as `"{var}"` or
// `"{var=*}"`, when such a variable is expanded into a URL path on the client
// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The
// server side does the reverse decoding. Such variables show up in the
// [Discovery
// Document](https://developers.google.com/discovery/v1/reference/apis) as
// `{var}`.
//
// If a variable contains multiple path segments, such as `"{var=foo/*}"`
// or `"{var=**}"`, when such a variable is expanded into a URL path on the
// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded.
// The server side does the reverse decoding, except "%2F" and "%2f" are left
// unchanged. Such variables show up in the
// [Discovery
// Document](https://developers.google.com/discovery/v1/reference/apis) as
// `{+var}`.
//
// ## Using gRPC API Service Configuration
//
// gRPC API Service Configuration (service config) is a configuration language
// for configuring a gRPC service to become a user-facing product. The
// service config is simply the YAML representation of the `google.api.Service`
// proto message.
//
// As an alternative to annotating your proto file, you can configure gRPC
// transcoding in your service config YAML files. You do this by specifying a
// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same
// effect as the proto annotation. This can be particularly useful if you
// have a proto that is reused in multiple services. Note that any transcoding
// specified in the service config will override any matching transcoding
// configuration in the proto.
//
// Example:
//
// http:
// rules:
// # Selects a gRPC method and applies HttpRule to it.
// - selector: example.v1.Messaging.GetMessage
// get: /v1/messages/{message_id}/{sub.subfield}
//
// ## Special notes
//
// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the
// proto to JSON conversion must follow the [proto3
// specification](https://developers.google.com/protocol-buffers/docs/proto3#json).
//
// While the single segment variable follows the semantics of
// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String
// Expansion, the multi segment variable **does not** follow RFC 6570 Section
// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion
// does not expand special characters like `?` and `#`, which would lead
// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding
// for multi segment variables.
//
// The path variables **must not** refer to any repeated or mapped field,
// because client libraries are not capable of handling such variable expansion.
//
// The path variables **must not** capture the leading "/" character. The reason
// is that the most common use case "{var}" does not capture the leading "/"
// character. For consistency, all path variables must share the same behavior.
//
// Repeated message fields must not be mapped to URL query parameters, because
// no client library can support such complicated mapping.
//
// If an API needs to use a JSON array for request or response body, it can map
// the request or response body to a repeated field. However, some gRPC
// Transcoding implementations may not support this feature.
message HttpRule {
// Selects a method to which this rule applies.
//
// Refer to [selector][google.api.DocumentationRule.selector] for syntax details.
string selector = 1;
// Determines the URL pattern is matched by this rules. This pattern can be
// used with any of the {get|put|post|delete|patch} methods. A custom method
// can be defined using the 'custom' field.
oneof pattern {
// Maps to HTTP GET. Used for listing and getting information about
// resources.
string get = 2;
// Maps to HTTP PUT. Used for replacing a resource.
string put = 3;
// Maps to HTTP POST. Used for creating a resource or performing an action.
string post = 4;
// Maps to HTTP DELETE. Used for deleting a resource.
string delete = 5;
// Maps to HTTP PATCH. Used for updating a resource.
string patch = 6;
// The custom pattern is used for specifying an HTTP method that is not
// included in the `pattern` field, such as HEAD, or "*" to leave the
// HTTP method unspecified for this rule. The wild-card rule is useful
// for services that provide content to Web (HTML) clients.
CustomHttpPattern custom = 8;
}
// The name of the request field whose value is mapped to the HTTP request
// body, or `*` for mapping all request fields not captured by the path
// pattern to the HTTP body, or omitted for not having any HTTP request body.
//
// NOTE: the referred field must be present at the top-level of the request
// message type.
string body = 7;
// Optional. The name of the response field whose value is mapped to the HTTP
// response body. When omitted, the entire response message will be used
// as the HTTP response body.
//
// NOTE: The referred field must be present at the top-level of the response
// message type.
string response_body = 12;
// Additional HTTP bindings for the selector. Nested bindings must
// not contain an `additional_bindings` field themselves (that is,
// the nesting may only be one level deep).
repeated HttpRule additional_bindings = 11;
}
// A custom pattern is used for defining custom HTTP verb.
message CustomHttpPattern {
// The name of this custom HTTP verb.
string kind = 1;
// The path matched by this custom verb.
string path = 2;
}

View File

@@ -0,0 +1,44 @@
syntax = "proto3";
package grpc.gateway.protoc_gen_openapiv2.options;
import "google/protobuf/descriptor.proto";
import "protoc-gen-openapiv2/options/openapiv2.proto";
option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options";
extend google.protobuf.FileOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Swagger openapiv2_swagger = 1042;
}
extend google.protobuf.MethodOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Operation openapiv2_operation = 1042;
}
extend google.protobuf.MessageOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Schema openapiv2_schema = 1042;
}
extend google.protobuf.ServiceOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Tag openapiv2_tag = 1042;
}
extend google.protobuf.FieldOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
JSONSchema openapiv2_field = 1042;
}

View File

@@ -0,0 +1,720 @@
syntax = "proto3";
package grpc.gateway.protoc_gen_openapiv2.options;
import "google/protobuf/struct.proto";
option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options";
// Scheme describes the schemes supported by the OpenAPI Swagger
// and Operation objects.
enum Scheme {
UNKNOWN = 0;
HTTP = 1;
HTTPS = 2;
WS = 3;
WSS = 4;
}
// `Swagger` is a representation of OpenAPI v2 specification's Swagger object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#swaggerObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// title: "Echo API";
// version: "1.0";
// description: "";
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE.txt";
// };
// };
// schemes: HTTPS;
// consumes: "application/json";
// produces: "application/json";
// };
//
message Swagger {
// Specifies the OpenAPI Specification version being used. It can be
// used by the OpenAPI UI and other clients to interpret the API listing. The
// value MUST be "2.0".
string swagger = 1;
// Provides metadata about the API. The metadata can be used by the
// clients if needed.
Info info = 2;
// The host (name or ip) serving the API. This MUST be the host only and does
// not include the scheme nor sub-paths. It MAY include a port. If the host is
// not included, the host serving the documentation is to be used (including
// the port). The host does not support path templating.
string host = 3;
// The base path on which the API is served, which is relative to the host. If
// it is not included, the API is served directly under the host. The value
// MUST start with a leading slash (/). The basePath does not support path
// templating.
// Note that using `base_path` does not change the endpoint paths that are
// generated in the resulting OpenAPI file. If you wish to use `base_path`
// with relatively generated OpenAPI paths, the `base_path` prefix must be
// manually removed from your `google.api.http` paths and your code changed to
// serve the API from the `base_path`.
string base_path = 4;
// The transfer protocol of the API. Values MUST be from the list: "http",
// "https", "ws", "wss". If the schemes is not included, the default scheme to
// be used is the one used to access the OpenAPI definition itself.
repeated Scheme schemes = 5;
// A list of MIME types the APIs can consume. This is global to all APIs but
// can be overridden on specific API calls. Value MUST be as described under
// Mime Types.
repeated string consumes = 6;
// A list of MIME types the APIs can produce. This is global to all APIs but
// can be overridden on specific API calls. Value MUST be as described under
// Mime Types.
repeated string produces = 7;
// field 8 is reserved for 'paths'.
reserved 8;
// field 9 is reserved for 'definitions', which at this time are already
// exposed as and customizable as proto messages.
reserved 9;
// An object to hold responses that can be used across operations. This
// property does not define global responses for all operations.
map<string, Response> responses = 10;
// Security scheme definitions that can be used across the specification.
SecurityDefinitions security_definitions = 11;
// A declaration of which security schemes are applied for the API as a whole.
// The list of values describes alternative security schemes that can be used
// (that is, there is a logical OR between the security requirements).
// Individual operations can override this definition.
repeated SecurityRequirement security = 12;
// A list of tags for API documentation control. Tags can be used for logical
// grouping of operations by resources or any other qualifier.
repeated Tag tags = 13;
// Additional external documentation.
ExternalDocumentation external_docs = 14;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 15;
}
// `Operation` is a representation of OpenAPI v2 specification's Operation object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#operationObject
//
// Example:
//
// service EchoService {
// rpc Echo(SimpleMessage) returns (SimpleMessage) {
// option (google.api.http) = {
// get: "/v1/example/echo/{id}"
// };
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
// summary: "Get a message.";
// operation_id: "getMessage";
// tags: "echo";
// responses: {
// key: "200"
// value: {
// description: "OK";
// }
// }
// };
// }
// }
message Operation {
// A list of tags for API documentation control. Tags can be used for logical
// grouping of operations by resources or any other qualifier.
repeated string tags = 1;
// A short summary of what the operation does. For maximum readability in the
// swagger-ui, this field SHOULD be less than 120 characters.
string summary = 2;
// A verbose explanation of the operation behavior. GFM syntax can be used for
// rich text representation.
string description = 3;
// Additional external documentation for this operation.
ExternalDocumentation external_docs = 4;
// Unique string used to identify the operation. The id MUST be unique among
// all operations described in the API. Tools and libraries MAY use the
// operationId to uniquely identify an operation, therefore, it is recommended
// to follow common programming naming conventions.
string operation_id = 5;
// A list of MIME types the operation can consume. This overrides the consumes
// definition at the OpenAPI Object. An empty value MAY be used to clear the
// global definition. Value MUST be as described under Mime Types.
repeated string consumes = 6;
// A list of MIME types the operation can produce. This overrides the produces
// definition at the OpenAPI Object. An empty value MAY be used to clear the
// global definition. Value MUST be as described under Mime Types.
repeated string produces = 7;
// field 8 is reserved for 'parameters'.
reserved 8;
// The list of possible responses as they are returned from executing this
// operation.
map<string, Response> responses = 9;
// The transfer protocol for the operation. Values MUST be from the list:
// "http", "https", "ws", "wss". The value overrides the OpenAPI Object
// schemes definition.
repeated Scheme schemes = 10;
// Declares this operation to be deprecated. Usage of the declared operation
// should be refrained. Default value is false.
bool deprecated = 11;
// A declaration of which security schemes are applied for this operation. The
// list of values describes alternative security schemes that can be used
// (that is, there is a logical OR between the security requirements). This
// definition overrides any declared top-level security. To remove a top-level
// security declaration, an empty array can be used.
repeated SecurityRequirement security = 12;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 13;
// Custom parameters such as HTTP request headers.
// See: https://swagger.io/docs/specification/2-0/describing-parameters/
// and https://swagger.io/specification/v2/#parameter-object.
Parameters parameters = 14;
}
// `Parameters` is a representation of OpenAPI v2 specification's parameters object.
// Note: This technically breaks compatibility with the OpenAPI 2 definition structure as we only
// allow header parameters to be set here since we do not want users specifying custom non-header
// parameters beyond those inferred from the Protobuf schema.
// See: https://swagger.io/specification/v2/#parameter-object
message Parameters {
// `Headers` is one or more HTTP header parameter.
// See: https://swagger.io/docs/specification/2-0/describing-parameters/#header-parameters
repeated HeaderParameter headers = 1;
}
// `HeaderParameter` a HTTP header parameter.
// See: https://swagger.io/specification/v2/#parameter-object
message HeaderParameter {
// `Type` is a a supported HTTP header type.
// See https://swagger.io/specification/v2/#parameterType.
enum Type {
UNKNOWN = 0;
STRING = 1;
NUMBER = 2;
INTEGER = 3;
BOOLEAN = 4;
}
// `Name` is the header name.
string name = 1;
// `Description` is a short description of the header.
string description = 2;
// `Type` is the type of the object. The value MUST be one of "string", "number", "integer", or "boolean". The "array" type is not supported.
// See: https://swagger.io/specification/v2/#parameterType.
Type type = 3;
// `Format` The extending format for the previously mentioned type.
string format = 4;
// `Required` indicates if the header is optional
bool required = 5;
// field 6 is reserved for 'items', but in OpenAPI-specific way.
reserved 6;
// field 7 is reserved `Collection Format`. Determines the format of the array if type array is used.
reserved 7;
}
// `Header` is a representation of OpenAPI v2 specification's Header object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#headerObject
//
message Header {
// `Description` is a short description of the header.
string description = 1;
// The type of the object. The value MUST be one of "string", "number", "integer", or "boolean". The "array" type is not supported.
string type = 2;
// `Format` The extending format for the previously mentioned type.
string format = 3;
// field 4 is reserved for 'items', but in OpenAPI-specific way.
reserved 4;
// field 5 is reserved `Collection Format` Determines the format of the array if type array is used.
reserved 5;
// `Default` Declares the value of the header that the server will use if none is provided.
// See: https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-6.2.
// Unlike JSON Schema this value MUST conform to the defined type for the header.
string default = 6;
// field 7 is reserved for 'maximum'.
reserved 7;
// field 8 is reserved for 'exclusiveMaximum'.
reserved 8;
// field 9 is reserved for 'minimum'.
reserved 9;
// field 10 is reserved for 'exclusiveMinimum'.
reserved 10;
// field 11 is reserved for 'maxLength'.
reserved 11;
// field 12 is reserved for 'minLength'.
reserved 12;
// 'Pattern' See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.2.3.
string pattern = 13;
// field 14 is reserved for 'maxItems'.
reserved 14;
// field 15 is reserved for 'minItems'.
reserved 15;
// field 16 is reserved for 'uniqueItems'.
reserved 16;
// field 17 is reserved for 'enum'.
reserved 17;
// field 18 is reserved for 'multipleOf'.
reserved 18;
}
// `Response` is a representation of OpenAPI v2 specification's Response object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#responseObject
//
message Response {
// `Description` is a short description of the response.
// GFM syntax can be used for rich text representation.
string description = 1;
// `Schema` optionally defines the structure of the response.
// If `Schema` is not provided, it means there is no content to the response.
Schema schema = 2;
// `Headers` A list of headers that are sent with the response.
// `Header` name is expected to be a string in the canonical format of the MIME header key
// See: https://golang.org/pkg/net/textproto/#CanonicalMIMEHeaderKey
map<string, Header> headers = 3;
// `Examples` gives per-mimetype response examples.
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#example-object
map<string, string> examples = 4;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 5;
}
// `Info` is a representation of OpenAPI v2 specification's Info object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#infoObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// title: "Echo API";
// version: "1.0";
// description: "";
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE.txt";
// };
// };
// ...
// };
//
message Info {
// The title of the application.
string title = 1;
// A short description of the application. GFM syntax can be used for rich
// text representation.
string description = 2;
// The Terms of Service for the API.
string terms_of_service = 3;
// The contact information for the exposed API.
Contact contact = 4;
// The license information for the exposed API.
License license = 5;
// Provides the version of the application API (not to be confused
// with the specification version).
string version = 6;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 7;
}
// `Contact` is a representation of OpenAPI v2 specification's Contact object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#contactObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// ...
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// ...
// };
// ...
// };
//
message Contact {
// The identifying name of the contact person/organization.
string name = 1;
// The URL pointing to the contact information. MUST be in the format of a
// URL.
string url = 2;
// The email address of the contact person/organization. MUST be in the format
// of an email address.
string email = 3;
}
// `License` is a representation of OpenAPI v2 specification's License object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#licenseObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// ...
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE.txt";
// };
// ...
// };
// ...
// };
//
message License {
// The license name used for the API.
string name = 1;
// A URL to the license used for the API. MUST be in the format of a URL.
string url = 2;
}
// `ExternalDocumentation` is a representation of OpenAPI v2 specification's
// ExternalDocumentation object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#externalDocumentationObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// ...
// external_docs: {
// description: "More about gRPC-Gateway";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// }
// ...
// };
//
message ExternalDocumentation {
// A short description of the target documentation. GFM syntax can be used for
// rich text representation.
string description = 1;
// The URL for the target documentation. Value MUST be in the format
// of a URL.
string url = 2;
}
// `Schema` is a representation of OpenAPI v2 specification's Schema object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
message Schema {
JSONSchema json_schema = 1;
// Adds support for polymorphism. The discriminator is the schema property
// name that is used to differentiate between other schema that inherit this
// schema. The property name used MUST be defined at this schema and it MUST
// be in the required property list. When used, the value MUST be the name of
// this schema or any schema that inherits it.
string discriminator = 2;
// Relevant only for Schema "properties" definitions. Declares the property as
// "read only". This means that it MAY be sent as part of a response but MUST
// NOT be sent as part of the request. Properties marked as readOnly being
// true SHOULD NOT be in the required list of the defined schema. Default
// value is false.
bool read_only = 3;
// field 4 is reserved for 'xml'.
reserved 4;
// Additional external documentation for this schema.
ExternalDocumentation external_docs = 5;
// A free-form property to include an example of an instance for this schema in JSON.
// This is copied verbatim to the output.
string example = 6;
}
// `JSONSchema` represents properties from JSON Schema taken, and as used, in
// the OpenAPI v2 spec.
//
// This includes changes made by OpenAPI v2.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
// See also: https://cswr.github.io/JsonSchema/spec/basic_types/,
// https://github.com/json-schema-org/json-schema-spec/blob/master/schema.json
//
// Example:
//
// message SimpleMessage {
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
// json_schema: {
// title: "SimpleMessage"
// description: "A simple message."
// required: ["id"]
// }
// };
//
// // Id represents the message identifier.
// string id = 1; [
// (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
// description: "The unique identifier of the simple message."
// }];
// }
//
message JSONSchema {
// field 1 is reserved for '$id', omitted from OpenAPI v2.
reserved 1;
// field 2 is reserved for '$schema', omitted from OpenAPI v2.
reserved 2;
// Ref is used to define an external reference to include in the message.
// This could be a fully qualified proto message reference, and that type must
// be imported into the protofile. If no message is identified, the Ref will
// be used verbatim in the output.
// For example:
// `ref: ".google.protobuf.Timestamp"`.
string ref = 3;
// field 4 is reserved for '$comment', omitted from OpenAPI v2.
reserved 4;
// The title of the schema.
string title = 5;
// A short description of the schema.
string description = 6;
string default = 7;
bool read_only = 8;
// A free-form property to include a JSON example of this field. This is copied
// verbatim to the output swagger.json. Quotes must be escaped.
// This property is the same for 2.0 and 3.0.0 https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/3.0.0.md#schemaObject https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
string example = 9;
double multiple_of = 10;
// Maximum represents an inclusive upper limit for a numeric instance. The
// value of MUST be a number,
double maximum = 11;
bool exclusive_maximum = 12;
// minimum represents an inclusive lower limit for a numeric instance. The
// value of MUST be a number,
double minimum = 13;
bool exclusive_minimum = 14;
uint64 max_length = 15;
uint64 min_length = 16;
string pattern = 17;
// field 18 is reserved for 'additionalItems', omitted from OpenAPI v2.
reserved 18;
// field 19 is reserved for 'items', but in OpenAPI-specific way.
// TODO(ivucica): add 'items'?
reserved 19;
uint64 max_items = 20;
uint64 min_items = 21;
bool unique_items = 22;
// field 23 is reserved for 'contains', omitted from OpenAPI v2.
reserved 23;
uint64 max_properties = 24;
uint64 min_properties = 25;
repeated string required = 26;
// field 27 is reserved for 'additionalProperties', but in OpenAPI-specific
// way. TODO(ivucica): add 'additionalProperties'?
reserved 27;
// field 28 is reserved for 'definitions', omitted from OpenAPI v2.
reserved 28;
// field 29 is reserved for 'properties', but in OpenAPI-specific way.
// TODO(ivucica): add 'additionalProperties'?
reserved 29;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2:
// patternProperties, dependencies, propertyNames, const
reserved 30 to 33;
// Items in 'array' must be unique.
repeated string array = 34;
enum JSONSchemaSimpleTypes {
UNKNOWN = 0;
ARRAY = 1;
BOOLEAN = 2;
INTEGER = 3;
NULL = 4;
NUMBER = 5;
OBJECT = 6;
STRING = 7;
}
repeated JSONSchemaSimpleTypes type = 35;
// `Format`
string format = 36;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2: contentMediaType, contentEncoding, if, then, else
reserved 37 to 41;
// field 42 is reserved for 'allOf', but in OpenAPI-specific way.
// TODO(ivucica): add 'allOf'?
reserved 42;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2:
// anyOf, oneOf, not
reserved 43 to 45;
// Items in `enum` must be unique https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.5.1
repeated string enum = 46;
// Additional field level properties used when generating the OpenAPI v2 file.
FieldConfiguration field_configuration = 1001;
// 'FieldConfiguration' provides additional field level properties used when generating the OpenAPI v2 file.
// These properties are not defined by OpenAPIv2, but they are used to control the generation.
message FieldConfiguration {
// Alternative parameter name when used as path parameter. If set, this will
// be used as the complete parameter name when this field is used as a path
// parameter. Use this to avoid having auto generated path parameter names
// for overlapping paths.
string path_param_name = 47;
}
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 48;
}
// `Tag` is a representation of OpenAPI v2 specification's Tag object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#tagObject
//
message Tag {
// The name of the tag. Use it to allow override of the name of a
// global Tag object, then use that name to reference the tag throughout the
// OpenAPI file.
string name = 1;
// A short description for the tag. GFM syntax can be used for rich text
// representation.
string description = 2;
// Additional external documentation for this tag.
ExternalDocumentation external_docs = 3;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 4;
}
// `SecurityDefinitions` is a representation of OpenAPI v2 specification's
// Security Definitions object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securityDefinitionsObject
//
// A declaration of the security schemes available to be used in the
// specification. This does not enforce the security schemes on the operations
// and only serves to provide the relevant details for each scheme.
message SecurityDefinitions {
// A single security scheme definition, mapping a "name" to the scheme it
// defines.
map<string, SecurityScheme> security = 1;
}
// `SecurityScheme` is a representation of OpenAPI v2 specification's
// Security Scheme object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securitySchemeObject
//
// Allows the definition of a security scheme that can be used by the
// operations. Supported schemes are basic authentication, an API key (either as
// a header or as a query parameter) and OAuth2's common flows (implicit,
// password, application and access code).
message SecurityScheme {
// The type of the security scheme. Valid values are "basic",
// "apiKey" or "oauth2".
enum Type {
TYPE_INVALID = 0;
TYPE_BASIC = 1;
TYPE_API_KEY = 2;
TYPE_OAUTH2 = 3;
}
// The location of the API key. Valid values are "query" or "header".
enum In {
IN_INVALID = 0;
IN_QUERY = 1;
IN_HEADER = 2;
}
// The flow used by the OAuth2 security scheme. Valid values are
// "implicit", "password", "application" or "accessCode".
enum Flow {
FLOW_INVALID = 0;
FLOW_IMPLICIT = 1;
FLOW_PASSWORD = 2;
FLOW_APPLICATION = 3;
FLOW_ACCESS_CODE = 4;
}
// The type of the security scheme. Valid values are "basic",
// "apiKey" or "oauth2".
Type type = 1;
// A short description for security scheme.
string description = 2;
// The name of the header or query parameter to be used.
// Valid for apiKey.
string name = 3;
// The location of the API key. Valid values are "query" or
// "header".
// Valid for apiKey.
In in = 4;
// The flow used by the OAuth2 security scheme. Valid values are
// "implicit", "password", "application" or "accessCode".
// Valid for oauth2.
Flow flow = 5;
// The authorization URL to be used for this flow. This SHOULD be in
// the form of a URL.
// Valid for oauth2/implicit and oauth2/accessCode.
string authorization_url = 6;
// The token URL to be used for this flow. This SHOULD be in the
// form of a URL.
// Valid for oauth2/password, oauth2/application and oauth2/accessCode.
string token_url = 7;
// The available scopes for the OAuth2 security scheme.
// Valid for oauth2.
Scopes scopes = 8;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 9;
}
// `SecurityRequirement` is a representation of OpenAPI v2 specification's
// Security Requirement object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securityRequirementObject
//
// Lists the required security schemes to execute this operation. The object can
// have multiple security schemes declared in it which are all required (that
// is, there is a logical AND between the schemes).
//
// The name used for each property MUST correspond to a security scheme
// declared in the Security Definitions.
message SecurityRequirement {
// If the security scheme is of type "oauth2", then the value is a list of
// scope names required for the execution. For other security scheme types,
// the array MUST be empty.
message SecurityRequirementValue {
repeated string scope = 1;
}
// Each name must correspond to a security scheme which is declared in
// the Security Definitions. If the security scheme is of type "oauth2",
// then the value is a list of scope names required for the execution.
// For other security scheme types, the array MUST be empty.
map<string, SecurityRequirementValue> security_requirement = 1;
}
// `Scopes` is a representation of OpenAPI v2 specification's Scopes object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#scopesObject
//
// Lists the available scopes for an OAuth2 security scheme.
message Scopes {
// Maps between a name of a scope to a short description of it (as the value
// of the property).
map<string, string> scope = 1;
}

21
internal/app/cmd.go Normal file
View File

@@ -0,0 +1,21 @@
package app
import (
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/spf13/cobra"
)
func Centrifugo() *cobra.Command {
var configFile string
cmd := &cobra.Command{
Use: "",
Short: "Centrifugo",
Long: "Centrifugo – scalable real-time messaging server in language-agnostic way",
Run: func(cmd *cobra.Command, args []string) {
Run(cmd, configFile)
},
}
cmd.Flags().StringVarP(&configFile, "config", "c", "config.json", "path to config file")
config.DefineFlags(cmd)
return cmd
}

326
internal/app/engine.go Normal file
View File

@@ -0,0 +1,326 @@
package app
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/controllers"
"github.com/centrifugal/centrifugo/v6/internal/natsbroker"
"github.com/centrifugal/centrifugo/v6/internal/pgmapbroker"
"github.com/centrifugal/centrifugo/v6/internal/pgstreambroker"
"github.com/centrifugal/centrifugo/v6/internal/redisnatsbroker"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
)
func configureEngines(node *centrifuge.Node, cfgContainer *config.Container) error {
cfg := cfgContainer.Config()
var broker centrifuge.Broker
var presenceManager centrifuge.PresenceManager
if !cfg.Broker.Enabled || !cfg.PresenceManager.Enabled {
var err error
var engineMode string
switch cfg.Engine.Type {
case "memory":
broker, presenceManager, err = createMemoryEngine(node)
case "redis":
broker, presenceManager, engineMode, err = createRedisEngine(node, cfgContainer)
default:
return fmt.Errorf("unknown engine type: %s", cfg.Engine.Type)
}
event := log.Info().Str("engine_type", cfg.Engine.Type)
if engineMode != "" {
event.Str("engine_mode", engineMode)
}
event.Msg("initializing engine")
if err != nil {
return fmt.Errorf("error creating engine: %v", err)
}
} else {
log.Info().Msgf("both broker and presence manager enabled, skip engine initialization")
}
if cfg.Broker.Enabled {
var err error
var brokerMode string
switch cfg.Broker.Type {
case "memory":
broker, err = createMemoryBroker(node)
case "redis":
broker, brokerMode, err = createRedisBroker(node, cfgContainer)
case "nats":
broker, err = NatsBroker(node, cfg)
brokerMode = "nats"
case "postgres":
broker, err = createPostgresStreamBroker(node, cfg.Broker.Postgres)
brokerMode = "postgres"
case "redisnats":
if !cfg.EnableUnreleasedFeatures {
return fmt.Errorf("redisnats broker requires enable_unreleased_features on")
}
log.Warn().Msg("redisnats broker is not released, it may be changed or removed at any point")
redisBroker, redisBrokerMode, err := createRedisBroker(node, cfgContainer)
if err != nil {
return fmt.Errorf("error creating redis broker: %v", err)
}
brokerMode = redisBrokerMode + "+nats"
natsBroker, err := NatsBroker(node, cfg)
if err != nil {
return fmt.Errorf("error creating nats broker: %v", err)
}
broker, err = redisnatsbroker.New(natsBroker, redisBroker)
if err != nil {
return fmt.Errorf("error creating redisnats broker: %v", err)
}
default:
return fmt.Errorf("unknown broker type: %s", cfg.Broker.Type)
}
if err != nil {
return fmt.Errorf("error creating broker: %v", err)
}
event := log.Info().Str("broker_type", cfg.Broker.Type)
if brokerMode != "" {
event.Str("broker_mode", brokerMode)
}
event.Msg("broker is enabled, using it instead of broker from engine")
} else {
log.Info().Msgf("explicit broker not provided, using the one from engine")
}
if cfg.PresenceManager.Enabled {
var err error
var presenceManagerMode string
switch cfg.PresenceManager.Type {
case "memory":
presenceManager, err = createMemoryPresenceManager(node)
case "redis":
presenceManager, presenceManagerMode, err = createRedisPresenceManager(node, cfgContainer)
default:
return fmt.Errorf("unknown presence manager type: %s", cfg.PresenceManager.Type)
}
if err != nil {
return fmt.Errorf("error creating presence manager: %v", err)
}
event := log.Info().Str("presence_manager_type", cfg.PresenceManager.Type)
if presenceManagerMode != "" {
event.Str("presence_manager_mode", presenceManagerMode)
}
event.Msg("presence manager is enabled, using it instead of presence manager from engine")
} else {
log.Info().Msgf("explicit presence manager not provided, using the one from engine")
}
if cfg.Controller.Enabled {
controller, err := controllers.New(node, cfg.Controller)
if err != nil {
return fmt.Errorf("error creating controller: %v", err)
}
if controller != nil {
node.SetController(controller)
}
}
node.SetBroker(broker)
node.SetPresenceManager(presenceManager)
return nil
}
func createMemoryBroker(n *centrifuge.Node) (centrifuge.Broker, error) {
brokerConf, err := memoryBrokerConfig()
if err != nil {
return nil, err
}
broker, err := centrifuge.NewMemoryBroker(n, *brokerConf)
if err != nil {
return nil, err
}
return broker, nil
}
func createPostgresStreamBroker(node *centrifuge.Node, pgCfg configtypes.PostgresStreamBroker) (centrifuge.Broker, error) {
pgBrokerCfg := pgstreambroker.PostgresStreamBrokerConfig{
DSN: pgCfg.DSN,
TLS: pgCfg.TLS,
PoolSize: pgCfg.PoolSize,
NumShards: pgCfg.NumShards,
CleanupInterval: pgCfg.CleanupInterval.ToDuration(),
IdempotentResultTTL: pgCfg.IdempotentResultTTL.ToDuration(),
BinaryData: pgCfg.BinaryData,
StreamRetention: pgCfg.StreamRetention.ToDuration(),
UseNotify: pgCfg.UseNotify,
NotifyDSN: pgCfg.NotifyDSN,
TablePrefix: pgCfg.TablePrefix,
PartitionLookaheadDays: pgCfg.PartitionLookaheadDays,
PartitionRetentionDays: pgCfg.PartitionRetentionDays,
Outbox: pgstreambroker.OutboxConfig{
PollInterval: pgCfg.Outbox.PollInterval.ToDuration(),
BatchSize: pgCfg.Outbox.BatchSize,
},
}
broker, err := pgstreambroker.NewPostgresStreamBroker(node, pgBrokerCfg)
if err != nil {
return nil, fmt.Errorf("error creating Postgres stream broker: %w", err)
}
if !pgCfg.SkipSchemaInit {
if schemaErr := broker.EnsureSchema(context.Background()); schemaErr != nil {
return nil, fmt.Errorf("error initializing Postgres stream broker schema: %w", schemaErr)
}
}
return broker, nil
}
func createMemoryPresenceManager(n *centrifuge.Node) (centrifuge.PresenceManager, error) {
presenceManagerConf, err := memoryPresenceManagerConfig()
if err != nil {
return nil, err
}
return centrifuge.NewMemoryPresenceManager(n, *presenceManagerConf)
}
func createMemoryEngine(n *centrifuge.Node) (centrifuge.Broker, centrifuge.PresenceManager, error) {
broker, err := createMemoryBroker(n)
if err != nil {
return nil, nil, err
}
presenceManager, err := createMemoryPresenceManager(n)
if err != nil {
return nil, nil, err
}
return broker, presenceManager, nil
}
func memoryBrokerConfig() (*centrifuge.MemoryBrokerConfig, error) {
return &centrifuge.MemoryBrokerConfig{}, nil
}
func memoryPresenceManagerConfig() (*centrifuge.MemoryPresenceManagerConfig, error) {
return &centrifuge.MemoryPresenceManagerConfig{}, nil
}
func NatsBroker(node *centrifuge.Node, cfg config.Config) (*natsbroker.NatsBroker, error) {
return natsbroker.New(node, cfg.Broker.Nats)
}
func createRedisEngine(n *centrifuge.Node, cfgContainer *config.Container) (*centrifuge.RedisBroker, centrifuge.PresenceManager, string, error) {
cfg := cfgContainer.Config()
redisShards, mode, err := confighelpers.CentrifugeRedisShards(n, cfg.Engine.Redis.Redis)
if err != nil {
return nil, nil, mode, fmt.Errorf("error creating Redis shards: %w", err)
}
var broker *centrifuge.RedisBroker
if !cfg.Broker.Enabled {
broker, err = confighelpers.CentrifugeRedisBroker(
n, cfg.Engine.Redis.Prefix, redisShards, cfg.Engine.Redis.RedisBrokerCommon, false)
if err != nil {
return nil, nil, mode, fmt.Errorf("error creating Redis broker: %w", err)
}
}
var presenceManager centrifuge.PresenceManager
if !cfg.PresenceManager.Enabled {
presenceManager, err = confighelpers.CentrifugeRedisPresenceManager(
n, cfg.Engine.Redis.Prefix, redisShards, cfg.Engine.Redis.RedisPresenceManagerCommon)
if err != nil {
return nil, nil, mode, fmt.Errorf("error creating Redis presence manager: %w", err)
}
}
return broker, presenceManager, mode, nil
}
func createRedisBroker(n *centrifuge.Node, cfgContainer *config.Container) (*centrifuge.RedisBroker, string, error) {
cfg := cfgContainer.Config()
redisShards, mode, err := confighelpers.CentrifugeRedisShards(n, cfg.Broker.Redis.Redis)
if err != nil {
return nil, "", fmt.Errorf("error creating Redis shards: %w", err)
}
broker, err := confighelpers.CentrifugeRedisBroker(
n, cfg.Broker.Redis.Prefix, redisShards, cfg.Broker.Redis.RedisBrokerCommon, cfg.Broker.Type == "redisnats")
if err != nil {
return nil, mode, fmt.Errorf("error creating Redis broker: %w", err)
}
return broker, mode, nil
}
func createRedisPresenceManager(n *centrifuge.Node, cfgContainer *config.Container) (centrifuge.PresenceManager, string, error) {
cfg := cfgContainer.Config()
redisShards, mode, err := confighelpers.CentrifugeRedisShards(n, cfg.PresenceManager.Redis.Redis)
if err != nil {
return nil, "", fmt.Errorf("error creating Redis shards: %w", err)
}
presenceManager, err := confighelpers.CentrifugeRedisPresenceManager(
n, cfg.PresenceManager.Redis.Prefix, redisShards, cfg.PresenceManager.Redis.RedisPresenceManagerCommon)
if err != nil {
return nil, mode, fmt.Errorf("error creating Redis presence manager: %w", err)
}
return presenceManager, mode, nil
}
func configureMapBroker(node *centrifuge.Node, cfgContainer *config.Container) error {
cfg := cfgContainer.Config()
var mapBroker centrifuge.MapBroker
var mapBrokerMode string
var err error
switch cfg.MapBroker.Type {
case "memory":
mapBroker, err = centrifuge.NewMemoryMapBroker(node, centrifuge.MemoryMapBrokerConfig{})
case "redis":
var redisShards []*centrifuge.RedisShard
redisShards, mapBrokerMode, err = confighelpers.CentrifugeRedisShards(node, cfg.MapBroker.Redis.Redis)
if err != nil {
return fmt.Errorf("error creating Redis shards for map broker: %w", err)
}
mapBroker, err = confighelpers.CentrifugeRedisMapBroker(
node, cfg.MapBroker.Redis.Prefix, redisShards, cfg.MapBroker.Redis.RedisMapBrokerCommon)
case "postgres":
pgCfg := cfg.MapBroker.Postgres
pgBrokerCfg := pgmapbroker.PostgresMapBrokerConfig{
DSN: pgCfg.DSN,
TLS: pgCfg.TLS,
PoolSize: pgCfg.PoolSize,
NumShards: pgCfg.NumShards,
TTLCheckInterval: pgCfg.TTLCheckInterval.ToDuration(),
CleanupInterval: pgCfg.CleanupInterval.ToDuration(),
IdempotentResultTTL: pgCfg.IdempotentResultTTL.ToDuration(),
BinaryData: pgCfg.BinaryData,
StreamRetention: pgCfg.StreamRetention.ToDuration(),
UseNotify: pgCfg.UseNotify,
NotifyDSN: pgCfg.NotifyDSN,
TablePrefix: pgCfg.TablePrefix,
PartitionLookaheadDays: pgCfg.PartitionLookaheadDays,
PartitionRetentionDays: pgCfg.PartitionRetentionDays,
Outbox: pgmapbroker.OutboxConfig{
PollInterval: pgCfg.Outbox.PollInterval.ToDuration(),
BatchSize: pgCfg.Outbox.BatchSize,
},
}
mapBroker, err = pgmapbroker.NewPostgresMapBroker(node, pgBrokerCfg)
if err != nil {
return fmt.Errorf("error creating Postgres map broker: %w", err)
}
if !pgCfg.SkipSchemaInit {
pgBroker := mapBroker.(*pgmapbroker.PostgresMapBroker)
if schemaErr := pgBroker.EnsureSchema(context.Background()); schemaErr != nil {
return fmt.Errorf("error initializing Postgres map broker schema: %w", schemaErr)
}
}
mapBrokerMode = "postgres"
default:
return fmt.Errorf("unknown map broker type: %s", cfg.MapBroker.Type)
}
if err != nil {
return fmt.Errorf("error creating map broker: %v", err)
}
event := log.Info().Str("map_broker_type", cfg.MapBroker.Type)
if mapBrokerMode != "" {
event.Str("map_broker_mode", mapBrokerMode)
}
event.Msg("initializing map broker")
node.SetMapBroker(mapBroker)
return nil
}

23
internal/app/graphite.go Normal file
View File

@@ -0,0 +1,23 @@
package app
import (
"net"
"strconv"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/metrics/graphite"
"github.com/centrifugal/centrifuge"
"github.com/prometheus/client_golang/prometheus"
)
func graphiteExporter(cfg config.Config, nodeCfg centrifuge.Config) *graphite.Exporter {
return graphite.New(graphite.Config{
Address: net.JoinHostPort(cfg.Graphite.Host, strconv.Itoa(cfg.Graphite.Port)),
Gatherer: prometheus.DefaultGatherer,
Prefix: strings.TrimSuffix(cfg.Graphite.Prefix, ".") + "." + graphite.PreparePathComponent(nodeCfg.Name),
Interval: cfg.Graphite.Interval.ToDuration(),
Tags: cfg.Graphite.Tags,
})
}

120
internal/app/grpc.go Normal file
View File

@@ -0,0 +1,120 @@
package app
import (
"crypto/tls"
"fmt"
"net"
"strconv"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/unigrpc"
"github.com/centrifugal/centrifuge"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/reflection"
)
func runGRPCAPIServer(cfg config.Config, node *centrifuge.Node, useAPIOpentelemetry bool, grpcAPIExecutor *api.Executor) (*grpc.Server, error) {
grpcAPIAddr := net.JoinHostPort(cfg.GrpcAPI.Address, strconv.Itoa(cfg.GrpcAPI.Port))
grpcAPIConn, err := net.Listen("tcp", grpcAPIAddr)
if err != nil {
return nil, fmt.Errorf("cannot listen to address %s", grpcAPIAddr)
}
var grpcOpts []grpc.ServerOption
// NOTE: not a bug – GRPC API without a key is intentional and documented behaviour in
// Centrifugo v6, see https://centrifugal.dev/docs/server/server_api. Unlike the HTTP API,
// GRPC API is disabled by default and runs on its own port, separate from the main HTTP
// server, so it's expected to be exposed to an internal network only. It may also be
// protected without a key – by mutual TLS via grpc_api.tls, or JWKS-based auth in PRO.
// Do not make an empty key fail-closed here: it silently breaks existing setups. In v7 we
// may require at least one auth method unless an explicit insecure flag is set.
if cfg.GrpcAPI.Key != "" {
grpcOpts = append(grpcOpts, api.GRPCKeyAuth(cfg.GrpcAPI.Key))
}
if cfg.GrpcAPI.MaxReceiveMessageSize > 0 {
grpcOpts = append(grpcOpts, grpc.MaxRecvMsgSize(cfg.GrpcAPI.MaxReceiveMessageSize))
}
var grpcAPITLSConfig *tls.Config
if cfg.GrpcAPI.TLS.Enabled {
grpcAPITLSConfig, err = cfg.GrpcAPI.TLS.ToGoTLSConfig("grpc_api")
if err != nil {
return nil, fmt.Errorf("error getting TLS config for GRPC API: %v", err)
}
}
if grpcAPITLSConfig != nil {
grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(grpcAPITLSConfig)))
}
if cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.API {
grpcOpts = append(grpcOpts, grpc.StatsHandler(otelgrpc.NewServerHandler()))
}
grpcErrorMode, err := tools.OptionalStringChoice(cfg.GrpcAPI.ErrorMode, []string{config.TransportErrorMode})
if err != nil {
return nil, fmt.Errorf("can't extract grpc error mode: %v", err)
}
grpcAPIServer := grpc.NewServer(grpcOpts...)
_ = api.RegisterGRPCServerAPI(node, grpcAPIExecutor, grpcAPIServer, api.GRPCAPIServiceConfig{
UseOpenTelemetry: useAPIOpentelemetry,
UseTransportErrorMode: grpcErrorMode == config.TransportErrorMode,
})
if cfg.GrpcAPI.Reflection {
reflection.Register(grpcAPIServer)
}
log.Info().Msgf("serving GRPC API service on %s", grpcAPIAddr)
go func() {
if err := grpcAPIServer.Serve(grpcAPIConn); err != nil {
log.Fatal().Err(err).Msg("serve GRPC API")
}
}()
return grpcAPIServer, nil
}
func runGRPCUniServer(cfg config.Config, node *centrifuge.Node) (*grpc.Server, error) {
grpcUniAddr := net.JoinHostPort(cfg.UniGRPC.Address, strconv.Itoa(cfg.UniGRPC.Port))
grpcUniConn, err := net.Listen("tcp", grpcUniAddr)
if err != nil {
return nil, fmt.Errorf("cannot listen to address %s", grpcUniAddr)
}
var grpcOpts []grpc.ServerOption
grpcOpts = append(grpcOpts, grpc.ForceServerCodec(&unigrpc.RawCodec{}))
if cfg.UniGRPC.MaxReceiveMessageSize > 0 {
grpcOpts = append(grpcOpts, grpc.MaxRecvMsgSize(cfg.UniGRPC.MaxReceiveMessageSize))
}
var uniGrpcTLSConfig *tls.Config
if cfg.UniGRPC.TLS.Enabled {
uniGrpcTLSConfig, err = cfg.UniGRPC.TLS.ToGoTLSConfig("uni_grpc")
if err != nil {
return nil, fmt.Errorf("error getting TLS config for uni GRPC: %v", err)
}
}
if uniGrpcTLSConfig != nil {
grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(uniGrpcTLSConfig)))
}
keepAliveEnforcementPolicy := keepalive.EnforcementPolicy{
MinTime: 5 * time.Second,
}
keepAliveServerParams := keepalive.ServerParameters{
Time: 25 * time.Second,
Timeout: 5 * time.Second,
}
grpcOpts = append(grpcOpts, grpc.KeepaliveEnforcementPolicy(keepAliveEnforcementPolicy))
grpcOpts = append(grpcOpts, grpc.KeepaliveParams(keepAliveServerParams))
grpcUniServer := grpc.NewServer(grpcOpts...)
_ = unigrpc.RegisterService(grpcUniServer, unigrpc.NewService(node, cfg.UniGRPC))
log.Info().Msgf("serving unidirectional GRPC on %s", grpcUniAddr)
go func() {
if err := grpcUniServer.Serve(grpcUniConn); err != nil {
log.Fatal().Err(err).Msg("serve uni GRPC")
}
}()
return grpcUniServer, nil
}

47
internal/app/log.go Normal file
View File

@@ -0,0 +1,47 @@
package app
import (
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func logStartWarnings(cfg config.Config, cfgMeta config.Meta) {
if cfg.Client.Insecure {
log.Warn().Msg("INSECURE client mode enabled, make sure you understand risks")
}
if cfg.HttpAPI.Insecure {
log.Warn().Msg("INSECURE HTTP API mode enabled, make sure you understand risks")
}
if cfg.Admin.Insecure {
log.Warn().Msg("INSECURE admin mode enabled, make sure you understand risks")
}
if cfg.Client.InsecureSkipTokenSignatureVerify {
log.Warn().Msg("INSECURE skip token signature verify enabled, make sure you understand risks")
}
if cfg.Debug.Enabled {
log.Warn().Msg("DEBUG mode enabled, see on " + cfg.Debug.HandlerPrefix)
}
for _, key := range cfgMeta.UnknownKeys {
log.Warn().Str("key", key).Msg("unknown key in configuration file")
}
for _, key := range cfgMeta.UnknownEnvs {
log.Warn().Str("var", key).Msg("unknown var in environment")
}
for _, msg := range cfgMeta.DeprecationWarnings {
log.Warn().Msg(msg)
}
}
type httpErrorLogWriter struct {
zerolog.Logger
}
func (w *httpErrorLogWriter) Write(data []byte) (int, error) {
w.Logger.Warn().Msg(strings.TrimSpace(string(data)))
return len(data), nil
}

635
internal/app/mux.go Normal file
View File

@@ -0,0 +1,635 @@
package app
import (
"crypto/tls"
"errors"
stdlog "log"
"net"
"net/http"
"net/http/pprof"
"strconv"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/admin"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/conninit"
"github.com/centrifugal/centrifugo/v6/internal/health"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/swaggerui"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/unihttpstream"
"github.com/centrifugal/centrifugo/v6/internal/unisse"
"github.com/centrifugal/centrifugo/v6/internal/uniws"
"github.com/centrifugal/centrifugo/v6/internal/wt"
"github.com/centrifugal/centrifuge"
"github.com/justinas/alice"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// HandlerFlag is a bit mask of handlers that must be enabled in mux.
type HandlerFlag int
const (
// HandlerWebsocket enables Raw Websocket handler.
HandlerWebsocket HandlerFlag = 1 << iota
// HandlerWebtransport enables Webtransport handler (requires HTTP/3)
HandlerWebtransport
// HandlerAPI enables API handler.
HandlerAPI
// HandlerAdmin enables admin web interface.
HandlerAdmin
// HandlerDebug enables debug handlers.
HandlerDebug
// HandlerPrometheus enables Prometheus handler.
HandlerPrometheus
// HandlerHealth enables Health check endpoint.
HandlerHealth
// HandlerUniWebsocket enables unidirectional websocket endpoint.
HandlerUniWebsocket
// HandlerUniSSE enables unidirectional SSE endpoint.
HandlerUniSSE
// HandlerUniHTTPStream enables unidirectional HTTP stream endpoint.
HandlerUniHTTPStream
// HandlerSSE enables bidirectional SSE endpoint (with emulation layer).
HandlerSSE
// HandlerHTTPStream enables bidirectional HTTP stream endpoint (with emulation layer).
HandlerHTTPStream
// HandlerEmulation handles client-to-server requests in an emulation layer.
HandlerEmulation
// HandlerInit handles connection initialization.
HandlerInit
// HandlerSwagger handles swagger UI.
HandlerSwagger
)
var handlerText = map[HandlerFlag]string{
HandlerWebsocket: "websocket",
HandlerWebtransport: "webtransport",
HandlerAPI: "api",
HandlerAdmin: "admin",
HandlerDebug: "debug",
HandlerPrometheus: "prometheus",
HandlerHealth: "health",
HandlerUniWebsocket: "uni_websocket",
HandlerUniSSE: "uni_sse",
HandlerUniHTTPStream: "uni_http_stream",
HandlerSSE: "sse",
HandlerHTTPStream: "http_stream",
HandlerEmulation: "emulation",
HandlerInit: "init",
HandlerSwagger: "swagger",
}
func (flags HandlerFlag) String() string {
flagsOrdered := []HandlerFlag{HandlerWebsocket, HandlerWebtransport, HandlerHTTPStream, HandlerSSE, HandlerEmulation, HandlerAPI, HandlerAdmin, HandlerPrometheus, HandlerDebug, HandlerHealth, HandlerUniWebsocket, HandlerUniSSE, HandlerUniHTTPStream, HandlerSwagger, HandlerInit}
var endpoints []string
for _, flag := range flagsOrdered {
text, ok := handlerText[flag]
if !ok {
continue
}
if flags&flag != 0 {
endpoints = append(endpoints, text)
}
}
return strings.Join(endpoints, ", ")
}
// Mux returns a mux including set of default handlers for Centrifugo server.
func Mux(
n *centrifuge.Node, cfgContainer *config.Container, apiExecutor *api.Executor, flags HandlerFlag, keepHeadersInContext bool, wtServer *webtransport.Server,
) *http.ServeMux {
mux := http.NewServeMux()
cfg := cfgContainer.Config()
var commonMiddlewares []alice.Constructor
useLoggingMW := zerolog.GlobalLevel() <= zerolog.DebugLevel
if useLoggingMW {
commonMiddlewares = append(commonMiddlewares, middleware.LogRequest)
}
if cfg.Prometheus.Enabled && cfg.Prometheus.InstrumentHTTPHandlers {
commonMiddlewares = append(commonMiddlewares, middleware.HTTPServerInstrumentation)
}
basicMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
basicChain := alice.New(basicMiddlewares...)
if flags&HandlerDebug != 0 {
mux.Handle(cfg.Debug.HandlerPrefix+"/", basicChain.Then(http.HandlerFunc(pprof.Index)))
mux.Handle(cfg.Debug.HandlerPrefix+"/cmdline", basicChain.Then(http.HandlerFunc(pprof.Cmdline)))
mux.Handle(cfg.Debug.HandlerPrefix+"/profile", basicChain.Then(http.HandlerFunc(pprof.Profile)))
mux.Handle(cfg.Debug.HandlerPrefix+"/symbol", basicChain.Then(http.HandlerFunc(pprof.Symbol)))
mux.Handle(cfg.Debug.HandlerPrefix+"/trace", basicChain.Then(http.HandlerFunc(pprof.Trace)))
}
if flags&HandlerEmulation != 0 {
// register bidirectional emulation endpoint.
emulationMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
emulationMiddlewares = append(emulationMiddlewares, middleware.NewCORS(getCheckOrigin(cfg)).Middleware)
emulationChain := alice.New(emulationMiddlewares...)
emulationPrefix := strings.TrimRight(cfg.Emulation.HandlerPrefix, "/")
if emulationPrefix == "" {
emulationPrefix = "/"
}
mux.Handle(emulationPrefix, emulationChain.Then(centrifuge.NewEmulationHandler(n, emulationHandlerConfig(cfg))))
}
connMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
// The ConnLimit middleware enforces both the total connection cap and the
// connection rate limit, so install it when either is configured - otherwise
// connection_rate_limit is silently ignored when connection_limit is 0.
if cfg.Client.ConnectionLimit > 0 || cfg.Client.ConnectionRateLimit > 0 {
connLimitMW := middleware.NewConnLimit(n, cfgContainer)
connMiddlewares = append(connMiddlewares, connLimitMW.Middleware)
}
userIDHTTPHeader := cfg.Client.UserIDHTTPHeader
if userIDHTTPHeader != "" {
connMiddlewares = append(connMiddlewares, middleware.UserHeaderAuth(userIDHTTPHeader))
}
if keepHeadersInContext {
connMiddlewares = append(connMiddlewares, middleware.HeadersToContext)
}
connMiddlewares = append(connMiddlewares, middleware.NewCORS(getCheckOrigin(cfg)).Middleware)
connChain := alice.New(connMiddlewares...)
if flags&HandlerWebsocket != 0 {
// register WebSocket connection endpoint.
wsPrefix := strings.TrimRight(cfg.WebSocket.HandlerPrefix, "/")
if wsPrefix == "" {
wsPrefix = "/"
}
mux.Handle(wsPrefix, connChain.Then(centrifuge.NewWebsocketHandler(n, websocketHandlerConfig(cfg))))
}
if flags&HandlerWebtransport != 0 {
// register WebTransport connection endpoint.
wtPrefix := strings.TrimRight(cfg.WebTransport.HandlerPrefix, "/")
if wtPrefix == "" {
wtPrefix = "/"
}
mux.Handle(wtPrefix, connChain.Then(wt.NewHandler(n, wtServer, cfg.WebTransport, getPingPongConfig(cfg))))
}
if flags&HandlerInit != 0 {
// register connection initialization endpoint.
connInitPrefix := strings.TrimRight(cfg.Init.HandlerPrefix, "/")
if connInitPrefix == "" {
connInitPrefix = "/"
}
mux.Handle(connInitPrefix, middleware.Method(http.MethodGet, connChain.Then(conninit.NewHandler())))
}
if flags&HandlerHTTPStream != 0 {
// register bidirectional HTTP stream connection endpoint.
streamPrefix := strings.TrimRight(cfg.HTTPStream.HandlerPrefix, "/")
if streamPrefix == "" {
streamPrefix = "/"
}
mux.Handle(streamPrefix, connChain.Then(centrifuge.NewHTTPStreamHandler(n, httpStreamHandlerConfig(cfg))))
}
if flags&HandlerSSE != 0 {
// register bidirectional SSE connection endpoint.
ssePrefix := strings.TrimRight(cfg.SSE.HandlerPrefix, "/")
if ssePrefix == "" {
ssePrefix = "/"
}
mux.Handle(ssePrefix, connChain.Then(centrifuge.NewSSEHandler(n, sseHandlerConfig(cfg))))
}
if flags&HandlerUniWebsocket != 0 {
// register unidirectional WebSocket connection endpoint.
wsPrefix := strings.TrimRight(cfg.UniWS.HandlerPrefix, "/")
if wsPrefix == "" {
wsPrefix = "/"
}
mux.Handle(wsPrefix, connChain.Then(
uniws.NewHandler(n, cfg.UniWS, getCheckOrigin(cfg), getPingPongConfig(cfg))))
}
if flags&HandlerUniSSE != 0 {
// register unidirectional SSE connection endpoint.
ssePrefix := strings.TrimRight(cfg.UniSSE.HandlerPrefix, "/")
if ssePrefix == "" {
ssePrefix = "/"
}
mux.Handle(ssePrefix, connChain.Then(unisse.NewHandler(n, cfg.UniSSE, getPingPongConfig(cfg))))
}
if flags&HandlerUniHTTPStream != 0 {
// register unidirectional HTTP stream connection endpoint.
streamPrefix := strings.TrimRight(cfg.UniHTTPStream.HandlerPrefix, "/")
if streamPrefix == "" {
streamPrefix = "/"
}
mux.Handle(streamPrefix, connChain.Then(unihttpstream.NewHandler(n, cfg.UniHTTPStream, getPingPongConfig(cfg))))
}
if flags&HandlerAPI != 0 {
// register HTTP API endpoints.
httpErrorMode, err := tools.OptionalStringChoice(cfg.HttpAPI.ErrorMode, []string{config.TransportErrorMode})
if err != nil {
log.Fatal().Err(err).Msg("error in config")
}
useOpenTelemetry := cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.API
apiHandler := api.NewHandler(n, apiExecutor, api.Config{
UseOpenTelemetry: useOpenTelemetry,
UseTransportErrorMode: httpErrorMode == config.TransportErrorMode,
})
apiPrefix := strings.TrimRight(cfg.HttpAPI.HandlerPrefix, "/")
if apiPrefix == "" {
apiPrefix = "/"
}
apiChain := func(op string) alice.Chain {
apiMiddlewares := append([]alice.Constructor{}, commonMiddlewares...)
otelHandler := middleware.NewOpenTelemetryHandler(op, nil)
if useOpenTelemetry {
apiMiddlewares = append(apiMiddlewares, otelHandler.Middleware)
}
apiMiddlewares = append(apiMiddlewares, middleware.Post)
if !cfg.HttpAPI.Insecure {
apiMiddlewares = append(apiMiddlewares, middleware.NewAPIKeyAuth(cfg.HttpAPI.Key).Middleware)
}
apiChain := alice.New(apiMiddlewares...)
return apiChain
}
mux.Handle(apiPrefix, apiChain(apiPrefix).Then(apiHandler.OldRoute()))
if apiPrefix != "/" {
for path, handler := range apiHandler.Routes() {
handlePath := apiPrefix + path
mux.Handle(handlePath, apiChain(handlePath).Then(handler))
}
} else {
for path, handler := range apiHandler.Routes() {
mux.Handle(path, apiChain(path).Then(handler))
}
}
}
if flags&HandlerSwagger != 0 {
// register Swagger UI endpoint.
swaggerPrefix := strings.TrimRight(cfg.Swagger.HandlerPrefix, "/") + "/"
if swaggerPrefix == "" {
swaggerPrefix = "/"
}
mux.Handle(swaggerPrefix, basicChain.Then(http.StripPrefix(swaggerPrefix, http.FileServer(swaggerui.FS))))
}
if flags&HandlerPrometheus != 0 {
// register Prometheus metrics export endpoint.
prometheusPrefix := strings.TrimRight(cfg.Prometheus.HandlerPrefix, "/")
if prometheusPrefix == "" {
prometheusPrefix = "/"
}
mux.Handle(prometheusPrefix, basicChain.Then(promhttp.Handler()))
}
if flags&HandlerAdmin != 0 {
adminPrefix := strings.TrimRight(cfg.Admin.HandlerPrefix, "/")
mux.Handle(adminPrefix+"/", basicChain.Then(admin.NewHandler(n, apiExecutor, cfg.Admin)))
}
if flags&HandlerHealth != 0 {
healthPrefix := strings.TrimRight(cfg.Health.HandlerPrefix, "/")
if healthPrefix == "" {
healthPrefix = "/"
}
mux.Handle(healthPrefix, basicChain.Then(health.NewHandler(n, health.Config{})))
}
return mux
}
func getPingPongConfig(cfg config.Config) centrifuge.PingPongConfig {
pingInterval := cfg.Client.PingInterval
pongTimeout := cfg.Client.PongTimeout
if pingInterval <= pongTimeout {
log.Fatal().Str("ping_interval", pingInterval.String()).Str("pong_timeout", pongTimeout.String()).
Msg("ping_interval must be greater than pong_timeout")
}
return centrifuge.PingPongConfig{
PingInterval: pingInterval.ToDuration(),
PongTimeout: pongTimeout.ToDuration(),
}
}
func websocketHandlerConfig(appCfg config.Config) centrifuge.WebsocketConfig {
cfg := centrifuge.WebsocketConfig{}
cfg.Compression = appCfg.WebSocket.Compression
cfg.CompressionLevel = appCfg.WebSocket.CompressionLevel
cfg.CompressionMinSize = appCfg.WebSocket.CompressionMinSize
cfg.ReadBufferSize = appCfg.WebSocket.ReadBufferSize
cfg.WriteBufferSize = appCfg.WebSocket.WriteBufferSize
cfg.UseWriteBufferPool = appCfg.WebSocket.UseWriteBufferPool
cfg.WriteTimeout = appCfg.WebSocket.WriteTimeout.ToDuration()
cfg.MessageSizeLimit = appCfg.WebSocket.MessageSizeLimit
cfg.DecompressedMessageSizeLimit = appCfg.WebSocket.DecompressedMessageSizeLimit
cfg.CheckOrigin = getCheckOrigin(appCfg)
cfg.PingPongConfig = getPingPongConfig(appCfg)
return cfg
}
func httpStreamHandlerConfig(appCfg config.Config) centrifuge.HTTPStreamConfig {
return centrifuge.HTTPStreamConfig{
MaxRequestBodySize: appCfg.HTTPStream.MaxRequestBodySize,
PingPongConfig: getPingPongConfig(appCfg),
}
}
func sseHandlerConfig(appCfg config.Config) centrifuge.SSEConfig {
return centrifuge.SSEConfig{
MaxRequestBodySize: appCfg.SSE.MaxRequestBodySize,
PingPongConfig: getPingPongConfig(appCfg),
}
}
func emulationHandlerConfig(cfg config.Config) centrifuge.EmulationConfig {
return centrifuge.EmulationConfig{
MaxRequestBodySize: cfg.Emulation.MaxRequestBodySize,
}
}
func runHTTPServers(
n *centrifuge.Node, cfgContainer *config.Container, apiExecutor *api.Executor, keepHeadersInContext bool,
) ([]*http.Server, error) {
cfg := cfgContainer.Config()
debug := cfg.Debug.Enabled
useAdmin := cfg.Admin.Enabled
usePrometheus := cfg.Prometheus.Enabled
useHealth := cfg.Health.Enabled
useSwagger := cfg.Swagger.Enabled
useConnInit := cfg.Init.Enabled
adminExternal := cfg.Admin.External
apiExternal := cfg.HttpAPI.External
apiDisabled := cfg.HttpAPI.Disabled
httpAddress := cfg.HTTP.Address
httpPort := strconv.Itoa(cfg.HTTP.Port)
httpInternalAddress := cfg.HTTP.InternalAddress
httpInternalPort := cfg.HTTP.InternalPort
if httpInternalAddress == "" && httpAddress != "" {
// If custom internal address not explicitly set we try to reuse main
// address for internal endpoints too.
httpInternalAddress = httpAddress
}
if httpInternalPort == "" {
// If custom internal port not set we use default http port for
// internal endpoints too.
httpInternalPort = httpPort
}
// addrToHandlerFlags contains mapping between HTTP server address and
// handler flags to serve on this address.
addrToHandlerFlags := map[string]HandlerFlag{}
var portFlags HandlerFlag
externalAddr := net.JoinHostPort(httpAddress, httpPort)
portFlags = addrToHandlerFlags[externalAddr]
if !cfg.WebSocket.Disabled {
portFlags |= HandlerWebsocket
}
if cfg.WebTransport.Enabled {
if !cfg.HTTP.HTTP3.Enabled {
log.Fatal().Msg("can not enable webtransport without experimental HTTP/3")
}
portFlags |= HandlerWebtransport
}
if cfg.SSE.Enabled {
portFlags |= HandlerSSE
}
if cfg.HTTPStream.Enabled {
portFlags |= HandlerHTTPStream
}
if cfg.SSE.Enabled || cfg.HTTPStream.Enabled {
portFlags |= HandlerEmulation
}
if useAdmin && adminExternal {
portFlags |= HandlerAdmin
}
if !apiDisabled && apiExternal {
portFlags |= HandlerAPI
}
if cfg.UniWS.Enabled {
portFlags |= HandlerUniWebsocket
}
if cfg.UniSSE.Enabled {
portFlags |= HandlerUniSSE
}
if cfg.UniHTTPStream.Enabled {
portFlags |= HandlerUniHTTPStream
}
if useConnInit {
portFlags |= HandlerInit
}
addrToHandlerFlags[externalAddr] = portFlags
internalAddr := net.JoinHostPort(httpInternalAddress, httpInternalPort)
portFlags = addrToHandlerFlags[internalAddr]
if !apiDisabled && !apiExternal {
portFlags |= HandlerAPI
}
if useAdmin && !adminExternal {
portFlags |= HandlerAdmin
}
if usePrometheus {
portFlags |= HandlerPrometheus
}
if useSwagger {
portFlags |= HandlerSwagger
}
if debug {
portFlags |= HandlerDebug
}
if useHealth {
portFlags |= HandlerHealth
}
addrToHandlerFlags[internalAddr] = portFlags
var servers []*http.Server
tlsConfig, err := GetTLSConfig(cfg)
if err != nil {
log.Fatal().Err(err).Msg("can not get TLS config")
}
var internalTLSConfig *tls.Config
if cfg.HTTP.InternalTLS.Enabled {
internalTLSConfig, err = cfg.HTTP.InternalTLS.ToGoTLSConfig("internal_tls")
if err != nil {
log.Fatal().Err(err).Msg("can not get internal TLS config")
}
}
// Iterate over port-to-flags mapping and start HTTP servers
// on separate ports serving handlers specified in flags.
for addr, handlerFlags := range addrToHandlerFlags {
addr := addr
if handlerFlags == 0 {
continue
}
var addrTLSConfig *tls.Config
if !cfg.HTTP.TLSExternal || addr == externalAddr {
addrTLSConfig = tlsConfig
}
if addr != externalAddr && cfg.HTTP.InternalTLS.Enabled {
addrTLSConfig = internalTLSConfig
}
useHTTP3 := cfg.HTTP.HTTP3.Enabled && addr == externalAddr
useWebtransport := useHTTP3 && handlerFlags&HandlerWebtransport != 0
var wtServer *webtransport.Server
if useWebtransport {
wtServer = &webtransport.Server{
CheckOrigin: getCheckOrigin(cfg),
}
}
mux := Mux(n, cfgContainer, apiExecutor, handlerFlags, keepHeadersInContext, wtServer)
var h3Server *http3.Server
if useHTTP3 {
h3Server = &http3.Server{
Addr: addr,
TLSConfig: addrTLSConfig,
Handler: mux,
}
if useWebtransport {
webtransport.ConfigureHTTP3Server(h3Server)
wtServer.H3 = h3Server
}
}
var protoSuffix string
if useHTTP3 {
protoSuffix = " with HTTP/3 (experimental)"
}
useH2C := cfg.HTTP.H2CExternal && addr == externalAddr && addrTLSConfig == nil
if useH2C {
protoSuffix = " with HTTP/2 cleartext"
}
log.Info().Msgf("serving %s endpoints on %s%s", handlerFlags, addr, protoSuffix)
var handler http.Handler = mux
if useHTTP3 {
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = h3Server.SetQUICHeaders(w.Header())
mux.ServeHTTP(w, r)
})
}
server := &http.Server{
Addr: addr,
Handler: handler,
TLSConfig: addrTLSConfig,
ErrorLog: stdlog.New(&httpErrorLogWriter{Logger: log.Logger}, "", 0),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Second,
}
if useH2C {
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
server.Protocols = protocols
}
servers = append(servers, server)
go func() {
if useHTTP3 {
if addrTLSConfig == nil {
log.Fatal().Msg("HTTP/3 requires TLS configured")
}
if cfg.HTTP.TLSAutocert.Enabled {
log.Fatal().Msg("can not use HTTP/3 with autocert")
}
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, resolve UDP")
}
udpConn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, listen UDP")
}
defer func() { _ = udpConn.Close() }()
tcpAddr, err := net.ResolveTCPAddr("tcp", addr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, resolve TCP")
}
tcpConn, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
log.Fatal().Err(err).Msg("can not start HTTP/3, listen TCP")
}
defer func() { _ = tcpConn.Close() }()
tlsConn := tls.NewListener(tcpConn, addrTLSConfig)
defer func() { _ = tlsConn.Close() }()
hErr := make(chan error)
qErr := make(chan error)
go func() {
hErr <- server.Serve(tlsConn)
}()
go func() {
if useWebtransport {
qErr <- wtServer.Serve(udpConn)
} else {
qErr <- h3Server.Serve(udpConn)
}
}()
select {
case err := <-hErr:
if useWebtransport {
_ = wtServer.Close()
} else {
_ = h3Server.Close()
}
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msg("error ListenAndServe")
}
case err := <-qErr:
// Cannot close the HTTP server or wait for requests to complete properly.
log.Fatal().Err(err).Msg("error ListenAndServe HTTP/3")
}
} else {
if addrTLSConfig != nil {
if err := server.ListenAndServeTLS("", ""); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msg("error ListenAndServeTLS")
}
}
} else {
if err := server.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msgf("error ListenAndServe")
}
}
}
}
}()
}
return servers, nil
}

122
internal/app/node.go Normal file
View File

@@ -0,0 +1,122 @@
package app
import (
"os"
"strconv"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/logging"
"github.com/centrifugal/centrifuge"
)
func centrifugeNodeConfig(version string, edition string, cfgContainer *config.Container, logHandler centrifuge.LogHandler) centrifuge.Config {
appCfg := cfgContainer.Config()
cfg := centrifuge.Config{}
cfg.Version = version + " " + strings.ToUpper(edition)
cfg.Metrics = centrifuge.MetricsConfig{
MetricsNamespace: "centrifugo",
EnableRecoveredPublicationsHistogram: appCfg.Prometheus.RecoveredPublicationsHistogram,
EnableNativeHistograms: appCfg.Prometheus.NativeHistograms,
}
cfg.Name = nodeName(appCfg)
cfg.ChannelMaxLength = appCfg.Channel.MaxLength
cfg.ClientPresenceUpdateInterval = appCfg.Client.PresenceUpdateInterval.ToDuration()
cfg.ClientExpiredCloseDelay = appCfg.Client.ExpiredCloseDelay.ToDuration()
cfg.ClientExpiredSubCloseDelay = appCfg.Client.ExpiredSubCloseDelay.ToDuration()
cfg.ClientStaleCloseDelay = appCfg.Client.StaleCloseDelay.ToDuration()
cfg.ClientQueueMaxSize = appCfg.Client.QueueMaxSize
cfg.ClientChannelLimit = appCfg.Client.ChannelLimit
cfg.ClientChannelPositionCheckDelay = appCfg.Client.ChannelPositionCheckDelay.ToDuration()
cfg.ClientChannelPositionMaxTimeLag = appCfg.Client.ChannelPositionMaxTimeLag.ToDuration()
cfg.UserConnectionLimit = appCfg.Client.UserConnectionLimit
cfg.NodeInfoMetricsAggregateInterval = appCfg.Node.InfoMetricsAggregateInterval.ToDuration()
cfg.HistoryMaxPublicationLimit = appCfg.Client.HistoryMaxPublicationLimit
cfg.RecoveryMaxPublicationLimit = appCfg.Client.RecoveryMaxPublicationLimit
cfg.Map.GetMapChannelOptions = func(channel string) centrifuge.MapChannelOptions {
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
if err != nil || !ok {
return centrifuge.MapChannelOptions{}
}
var mode centrifuge.MapMode
switch chOpts.Map.Mode {
case "ephemeral":
mode = centrifuge.MapModeEphemeral
case "recoverable":
mode = centrifuge.MapModeRecoverable
case "persistent":
mode = centrifuge.MapModePersistent
}
return centrifuge.MapChannelOptions{
Mode: mode,
KeyTTL: chOpts.Map.KeyTTL.ToDuration(),
StreamSize: chOpts.Map.StreamSize,
StreamTTL: chOpts.Map.StreamTTL.ToDuration(),
MetaTTL: chOpts.Map.MetaTTL.ToDuration(),
DefaultPageSize: chOpts.Map.DefaultPageSize,
MinPageSize: chOpts.Map.MinPageSize,
MaxPageSize: chOpts.Map.MaxPageSize,
LiveTransitionMaxPublicationLimit: chOpts.Map.LiveTransitionMaxPublicationLimit,
SubscribeCatchUpTimeout: chOpts.Map.SubscribeCatchUpTimeout.ToDuration(),
}
}
hasSharedPoll := appCfg.Channel.WithoutNamespace.SubscriptionType == "shared_poll"
if !hasSharedPoll {
for _, ns := range appCfg.Channel.Namespaces {
if ns.SubscriptionType == "shared_poll" {
hasSharedPoll = true
break
}
}
}
if hasSharedPoll {
cfg.SharedPoll.ConcurrencyLimit = appCfg.SharedPoll.ConcurrencyLimit
cfg.SharedPoll.GetSharedPollChannelOptions = func(channel string) (centrifuge.SharedPollChannelOptions, bool) {
_, _, chOpts, ok, err := cfgContainer.ChannelOptions(channel)
if err != nil || !ok {
return centrifuge.SharedPollChannelOptions{}, false
}
if chOpts.SubscriptionType != "shared_poll" {
return centrifuge.SharedPollChannelOptions{}, false
}
return centrifuge.SharedPollChannelOptions{
MaxKeysPerConnection: chOpts.SharedPoll.MaxKeysPerConnection,
RefreshInterval: chOpts.SharedPoll.RefreshInterval.ToDuration(),
RefreshBatchSize: chOpts.SharedPoll.RefreshBatchSize,
Mode: chOpts.SharedPoll.Mode,
ChannelShutdownDelay: chOpts.SharedPoll.ChannelShutdownDelay.ToDuration(),
TrackExpiredExtraDelay: chOpts.SharedPoll.TrackExpiredExtraDelay.ToDuration(),
PublishEnabled: chOpts.SharedPoll.PublishEnabled,
}, true
}
}
cfg.HistoryMetaTTL = appCfg.Channel.HistoryMetaTTL.ToDuration()
cfg.ClientConnectIncludeServerTime = appCfg.Client.ConnectIncludeServerTime
cfg.LogLevel = logging.CentrifugeLogLevel(strings.ToLower(appCfg.Log.Level))
cfg.LogHandler = logHandler
if appCfg.Client.ConnectCodeToUnidirectionalDisconnect.Enabled {
uniCodeTransforms := make(map[uint32]centrifuge.Disconnect)
for _, transform := range appCfg.Client.ConnectCodeToUnidirectionalDisconnect.Transforms {
uniCodeTransforms[transform.Code] = centrifuge.Disconnect{Code: transform.To.Code, Reason: transform.To.Reason}
}
cfg.UnidirectionalCodeToDisconnect = uniCodeTransforms
}
return cfg
}
// nodeName returns a name for this Centrifugo node. If no name provided
// in configuration then it constructs node name based on hostname and port
func nodeName(cfg config.Config) string {
name := cfg.Node.Name
if name != "" {
return name
}
port := strconv.Itoa(cfg.HTTP.Port)
var hostname string
hostname, err := os.Hostname()
if err != nil {
hostname = "?"
}
return hostname + "_" + port
}

49
internal/app/origin.go Normal file
View File

@@ -0,0 +1,49 @@
package app
import (
"net/http"
"sync"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/origin"
"github.com/rs/zerolog/log"
)
var warnAllowedOriginsOnce sync.Once
func getCheckOrigin(cfg config.Config) func(r *http.Request) bool {
allowedOrigins := cfg.Client.AllowedOrigins
if len(allowedOrigins) == 0 {
return func(r *http.Request) bool {
// Only allow connections without Origin in this case.
originHeader := r.Header.Get("Origin")
if originHeader == "" {
return true
}
log.Info().Str("origin", originHeader).Msg("request Origin is not authorized due to empty allowed_origins")
return false
}
}
originChecker, err := origin.NewPatternChecker(allowedOrigins)
if err != nil {
log.Fatal().Err(err).Msg("error creating origin checker")
}
if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
// Fast path for *.
warnAllowedOriginsOnce.Do(func() {
log.Warn().Msg("usage of allowed_origins * is discouraged for security reasons, consider setting exact list of origins")
})
return func(r *http.Request) bool {
return true
}
}
return func(r *http.Request) bool {
ok := originChecker.Check(r)
if !ok {
log.Info().Str("origin", r.Header.Get("Origin")).Strs("allowed_origins", allowedOrigins).Msg("request Origin is not authorized")
return false
}
return true
}
}

533
internal/app/proxy.go Normal file
View File

@@ -0,0 +1,533 @@
package app
import (
"fmt"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/client"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/proxy"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/rs/zerolog/log"
)
func buildProxyMap(cfg config.Config) (*client.ProxyMap, bool, error) {
proxyMap := &client.ProxyMap{
ConnectProxy: nil,
RefreshProxy: nil,
RpcProxies: map[string]proxy.RPCProxy{},
PublishProxies: map[string]proxy.PublishProxy{},
SubscribeProxies: map[string]proxy.SubscribeProxy{},
SubRefreshProxies: map[string]proxy.SubRefreshProxy{},
SubscribeStreamProxies: map[string]*proxy.SubscribeStreamProxy{},
MapPublishProxies: map[string]proxy.MapPublishProxy{},
MapRemoveProxies: map[string]proxy.MapRemoveProxy{},
SharedPollRefreshProxies: map[string]*proxy.SharedPollRefreshHandler{},
}
var keepHeadersInContext bool
var keepEmulatedHeadersInContext bool
var err error
var proxyFound bool
if cfg.Client.Proxy.Connect.Enabled {
p := cfg.Client.Proxy.Connect
proxyMap.ConnectProxy, err = proxy.GetConnectProxy("connect", cfg.Client.Proxy.Connect.Proxy)
if err != nil {
return nil, false, fmt.Errorf("error creating connect proxy: %w", err)
}
log.Info().Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("connect proxy enabled")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
if cfg.Client.Proxy.Refresh.Enabled {
p := cfg.Client.Proxy.Refresh
proxyMap.RefreshProxy, err = proxy.GetRefreshProxy("refresh", cfg.Client.Proxy.Refresh.Proxy)
if err != nil {
return nil, false, fmt.Errorf("error creating refresh proxy: %w", err)
}
log.Info().Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("refresh proxy enabled")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
namedProxies := make(map[string]configtypes.Proxy)
for _, p := range cfg.Proxies {
namedProxies[p.Name] = p.Proxy
}
subscribeProxyEnabled := cfg.Channel.WithoutNamespace.SubscribeProxyEnabled
subscribeProxyName := cfg.Channel.WithoutNamespace.SubscribeProxyName
if subscribeProxyEnabled {
var p proxy.Config
if subscribeProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Subscribe
} else {
p, proxyFound = namedProxies[subscribeProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe proxy not found: %s", subscribeProxyName)
}
}
if _, ok := proxyMap.SubscribeProxies[subscribeProxyName]; !ok {
sp, err := proxy.GetSubscribeProxy(subscribeProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe proxy %s: %w", subscribeProxyName, err)
}
proxyMap.SubscribeProxies[subscribeProxyName] = sp
}
log.Info().Str("proxy_name", subscribeProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("subscribe proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
publishProxyEnabled := cfg.Channel.WithoutNamespace.PublishProxyEnabled
publishProxyName := cfg.Channel.WithoutNamespace.PublishProxyName
if publishProxyEnabled {
var p proxy.Config
if publishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Publish
} else {
p, proxyFound = namedProxies[publishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("publish proxy not found: %s", publishProxyName)
}
}
if _, ok := proxyMap.PublishProxies[publishProxyName]; !ok {
pp, err := proxy.GetPublishProxy(publishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating publish proxy %s: %w", publishProxyName, err)
}
proxyMap.PublishProxies[publishProxyName] = pp
}
log.Info().Str("proxy_name", publishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("publish proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subRefreshProxyEnabled := cfg.Channel.WithoutNamespace.SubRefreshProxyEnabled
subRefreshProxyName := cfg.Channel.WithoutNamespace.SubRefreshProxyName
if subRefreshProxyEnabled {
var p proxy.Config
if subRefreshProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubRefresh
} else {
p, proxyFound = namedProxies[subRefreshProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("sub refresh proxy not found: %s", subRefreshProxyName)
}
}
if _, ok := proxyMap.SubRefreshProxies[subRefreshProxyName]; !ok {
srp, err := proxy.GetSubRefreshProxy(subRefreshProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating sub refresh proxy %s: %w", subRefreshProxyName, err)
}
proxyMap.SubRefreshProxies[subRefreshProxyName] = srp
}
log.Info().Str("proxy_name", subRefreshProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("sub refresh proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subscribeStreamProxyEnabled := cfg.Channel.WithoutNamespace.SubscribeStreamProxyEnabled
subscribeStreamProxyName := cfg.Channel.WithoutNamespace.SubscribeStreamProxyName
if subscribeStreamProxyEnabled {
var p proxy.Config
if subscribeStreamProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubscribeStream
} else {
p, proxyFound = namedProxies[subscribeStreamProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe stream proxy not found: %s", subscribeStreamProxyName)
}
}
if strings.HasPrefix(p.Endpoint, "http") {
log.Fatal().Str("name", subscribeStreamProxyName).Msg("error creating subscribe stream proxy – only GRPC endpoints supported")
}
if _, ok := proxyMap.SubscribeStreamProxies[subscribeStreamProxyName]; !ok {
sp, err := proxy.NewSubscribeStreamProxy(subscribeStreamProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe stream proxy %s: %w", subscribeStreamProxyName, err)
}
proxyMap.SubscribeStreamProxies[subscribeStreamProxyName] = sp
}
log.Info().Str("proxy_name", subscribeStreamProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("subscribe stream proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapPublishProxyEnabled := cfg.Channel.WithoutNamespace.Map.PublishProxyEnabled
mapPublishProxyName := cfg.Channel.WithoutNamespace.Map.PublishProxyName
if mapPublishProxyEnabled {
var p proxy.Config
if mapPublishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapPublish
} else {
p, proxyFound = namedProxies[mapPublishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map publish proxy not found: %s", mapPublishProxyName)
}
}
if _, ok := proxyMap.MapPublishProxies[mapPublishProxyName]; !ok {
mpp, err := proxy.GetMapPublishProxy(mapPublishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map publish proxy %s: %w", mapPublishProxyName, err)
}
proxyMap.MapPublishProxies[mapPublishProxyName] = mpp
}
log.Info().Str("proxy_name", mapPublishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("map publish proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapRemoveProxyEnabled := cfg.Channel.WithoutNamespace.Map.RemoveProxyEnabled
mapRemoveProxyName := cfg.Channel.WithoutNamespace.Map.RemoveProxyName
if mapRemoveProxyEnabled {
var p proxy.Config
if mapRemoveProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapRemove
} else {
p, proxyFound = namedProxies[mapRemoveProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map remove proxy not found: %s", mapRemoveProxyName)
}
}
if _, ok := proxyMap.MapRemoveProxies[mapRemoveProxyName]; !ok {
mrp, err := proxy.GetMapRemoveProxy(mapRemoveProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map remove proxy %s: %w", mapRemoveProxyName, err)
}
proxyMap.MapRemoveProxies[mapRemoveProxyName] = mrp
}
log.Info().Str("proxy_name", mapRemoveProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("map remove proxy enabled for channels without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
for _, ns := range cfg.Channel.Namespaces {
subscribeProxyEnabled := ns.SubscribeProxyEnabled
subscribeProxyName := ns.SubscribeProxyName
if subscribeProxyEnabled {
var p proxy.Config
if subscribeProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Subscribe
} else {
p, proxyFound = namedProxies[subscribeProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe proxy not found: %s", subscribeProxyName)
}
}
if _, ok := proxyMap.SubscribeProxies[subscribeProxyName]; !ok {
sp, err := proxy.GetSubscribeProxy(subscribeProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe proxy %s: %w", subscribeProxyName, err)
}
proxyMap.SubscribeProxies[subscribeProxyName] = sp
}
log.Info().Str("proxy_name", subscribeProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("subscribe proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
publishProxyEnabled := ns.PublishProxyEnabled
publishProxyName := ns.PublishProxyName
if publishProxyEnabled {
var p proxy.Config
if publishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.Publish
} else {
p, proxyFound = namedProxies[publishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("publish proxy not found: %s", publishProxyName)
}
}
if _, ok := proxyMap.PublishProxies[publishProxyName]; !ok {
pp, err := proxy.GetPublishProxy(publishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating publish proxy %s: %w", publishProxyName, err)
}
proxyMap.PublishProxies[publishProxyName] = pp
}
log.Info().Str("proxy_name", publishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("publish proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subRefreshProxyEnabled := ns.SubRefreshProxyEnabled
subRefreshProxyName := ns.SubRefreshProxyName
if subRefreshProxyEnabled {
var p proxy.Config
if subRefreshProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubRefresh
} else {
p, proxyFound = namedProxies[subRefreshProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("sub refresh proxy not found: %s", subRefreshProxyName)
}
}
if _, ok := proxyMap.SubRefreshProxies[subRefreshProxyName]; !ok {
srp, err := proxy.GetSubRefreshProxy(subRefreshProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating sub refresh proxy %s: %w", subRefreshProxyName, err)
}
proxyMap.SubRefreshProxies[subRefreshProxyName] = srp
}
log.Info().Str("proxy_name", subRefreshProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("sub refresh proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
subscribeStreamProxyEnabled := ns.SubscribeStreamProxyEnabled
subscribeStreamProxyName := ns.SubscribeStreamProxyName
if subscribeStreamProxyEnabled {
var p proxy.Config
if subscribeStreamProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SubscribeStream
} else {
p, proxyFound = namedProxies[subscribeStreamProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("subscribe stream proxy not found: %s", subscribeStreamProxyName)
}
}
if strings.HasPrefix(p.Endpoint, "http") {
return nil, false, fmt.Errorf("error creating subscribe stream proxy %s only GRPC endpoints supported", subscribeStreamProxyName)
}
if _, ok := proxyMap.SubscribeStreamProxies[subscribeStreamProxyName]; !ok {
sp, err := proxy.NewSubscribeStreamProxy(subscribeStreamProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating subscribe stream proxy %s: %w", subscribeStreamProxyName, err)
}
proxyMap.SubscribeStreamProxies[subscribeStreamProxyName] = sp
}
log.Info().Str("proxy_name", subscribeStreamProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("subscribe stream proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapPublishProxyEnabled := ns.Map.PublishProxyEnabled
mapPublishProxyName := ns.Map.PublishProxyName
if mapPublishProxyEnabled {
var p proxy.Config
if mapPublishProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapPublish
} else {
p, proxyFound = namedProxies[mapPublishProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map publish proxy not found: %s", mapPublishProxyName)
}
}
if _, ok := proxyMap.MapPublishProxies[mapPublishProxyName]; !ok {
mpp, err := proxy.GetMapPublishProxy(mapPublishProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map publish proxy %s: %w", mapPublishProxyName, err)
}
proxyMap.MapPublishProxies[mapPublishProxyName] = mpp
}
log.Info().Str("proxy_name", mapPublishProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("map publish proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
mapRemoveProxyEnabled := ns.Map.RemoveProxyEnabled
mapRemoveProxyName := ns.Map.RemoveProxyName
if mapRemoveProxyEnabled {
var p proxy.Config
if mapRemoveProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.MapRemove
} else {
p, proxyFound = namedProxies[mapRemoveProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("map remove proxy not found: %s", mapRemoveProxyName)
}
}
if _, ok := proxyMap.MapRemoveProxies[mapRemoveProxyName]; !ok {
mrp, err := proxy.GetMapRemoveProxy(mapRemoveProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating map remove proxy %s: %w", mapRemoveProxyName, err)
}
proxyMap.MapRemoveProxies[mapRemoveProxyName] = mrp
}
log.Info().Str("proxy_name", mapRemoveProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("map remove proxy enabled for channels in namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
if ns.SubscriptionType == "shared_poll" {
sharedPollProxyName := ns.SharedPoll.ProxyName
if sharedPollProxyName == "" {
sharedPollProxyName = config.DefaultProxyName
}
var p proxy.Config
if sharedPollProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SharedPollRefresh
} else {
p, proxyFound = namedProxies[sharedPollProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("shared poll refresh proxy not found: %s", sharedPollProxyName)
}
}
if _, ok := proxyMap.SharedPollRefreshProxies[sharedPollProxyName]; !ok {
sp, err := proxy.GetSharedPollRefreshProxy(sharedPollProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating shared poll refresh proxy %s: %w", sharedPollProxyName, err)
}
handler := proxy.NewSharedPollRefreshHandler(proxy.SharedPollRefreshHandlerConfig{
Proxy: sp,
Name: sharedPollProxyName,
})
proxyMap.SharedPollRefreshProxies[sharedPollProxyName] = handler
}
log.Info().Str("proxy_name", sharedPollProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("shared poll refresh proxy enabled for channels in namespace")
}
}
// Also check without-namespace channels for shared poll proxy.
if cfg.Channel.WithoutNamespace.SubscriptionType == "shared_poll" {
sharedPollProxyName := cfg.Channel.WithoutNamespace.SharedPoll.ProxyName
if sharedPollProxyName == "" {
sharedPollProxyName = config.DefaultProxyName
}
var p proxy.Config
if sharedPollProxyName == config.DefaultProxyName {
p = cfg.Channel.Proxy.SharedPollRefresh
} else {
p, proxyFound = namedProxies[sharedPollProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("shared poll refresh proxy not found: %s", sharedPollProxyName)
}
}
if _, ok := proxyMap.SharedPollRefreshProxies[sharedPollProxyName]; !ok {
sp, err := proxy.GetSharedPollRefreshProxy(sharedPollProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating shared poll refresh proxy %s: %w", sharedPollProxyName, err)
}
handler := proxy.NewSharedPollRefreshHandler(proxy.SharedPollRefreshHandlerConfig{
Proxy: sp,
Name: sharedPollProxyName,
})
proxyMap.SharedPollRefreshProxies[sharedPollProxyName] = handler
}
log.Info().Str("proxy_name", sharedPollProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("shared poll refresh proxy enabled for channels without namespace")
}
rpcProxyEnabled := cfg.RPC.WithoutNamespace.ProxyEnabled
rpcProxyName := cfg.RPC.WithoutNamespace.ProxyName
if rpcProxyEnabled {
var p proxy.Config
if rpcProxyName == config.DefaultProxyName {
p = cfg.RPC.Proxy
} else {
p, proxyFound = namedProxies[rpcProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("rpc proxy not found: %s", rpcProxyName)
}
}
if _, ok := proxyMap.RpcProxies[rpcProxyName]; !ok {
rp, err := proxy.GetRpcProxy(rpcProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating rpc proxy %s: %w", rpcProxyName, err)
}
proxyMap.RpcProxies[rpcProxyName] = rp
}
log.Info().Str("proxy_name", rpcProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Msg("RPC proxy enabled for methods without namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
for _, ns := range cfg.RPC.Namespaces {
rpcProxyEnabled := ns.ProxyEnabled
rpcProxyName := ns.ProxyName
if rpcProxyEnabled {
var p proxy.Config
if rpcProxyName == config.DefaultProxyName {
p = cfg.RPC.Proxy
} else {
p, proxyFound = namedProxies[rpcProxyName]
if !proxyFound {
return nil, false, fmt.Errorf("rpc proxy not found: %s", rpcProxyName)
}
}
if _, ok := proxyMap.RpcProxies[rpcProxyName]; !ok {
rp, err := proxy.GetRpcProxy(rpcProxyName, p)
if err != nil {
return nil, false, fmt.Errorf("error creating rpc proxy %s: %w", rpcProxyName, err)
}
proxyMap.RpcProxies[rpcProxyName] = rp
}
log.Info().Str("proxy_name", rpcProxyName).Str("endpoint", tools.RedactedLogURLs(p.Endpoint)[0]).Str("namespace", ns.Name).Msg("RPC proxy enabled for namespace")
if len(p.HttpHeaders) > 0 {
keepHeadersInContext = true
}
if len(p.EmulatedHeaders) > 0 {
keepEmulatedHeadersInContext = true
}
}
}
proxyMap.UsesEmulatedHeaders = keepEmulatedHeadersInContext
return proxyMap, keepHeadersInContext, nil
}

445
internal/app/run.go Normal file
View File

@@ -0,0 +1,445 @@
package app
import (
"context"
"crypto/fips140"
"errors"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/build"
"github.com/centrifugal/centrifugo/v6/internal/client"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/consuming"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/centrifugal/centrifugo/v6/internal/logging"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/notify"
"github.com/centrifugal/centrifugo/v6/internal/service"
"github.com/centrifugal/centrifugo/v6/internal/survey"
"github.com/centrifugal/centrifugo/v6/internal/telemetry"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/usage"
"github.com/centrifugal/centrifuge"
"github.com/joho/godotenv"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.uber.org/automaxprocs/maxprocs"
"google.golang.org/grpc"
_ "google.golang.org/grpc/encoding/gzip"
)
const edition = "oss"
func Run(cmd *cobra.Command, configFile string) {
dotEnvUsed := false
if tools.FileExists(".env") {
err := godotenv.Load()
if err != nil {
log.Fatal().Err(err).Msg("error loading .env file")
}
dotEnvUsed = true
}
cfg, cfgMeta, err := config.GetConfig(cmd, configFile)
if err != nil {
log.Fatal().Err(err).Msg("error getting config")
}
ctx, serviceCancel := context.WithCancel(context.Background())
defer serviceCancel()
// Ingest services (async consumers) have a separate lifecycle from the rest of
// services – see the shutdown sequence in handleSignals.
ingestCtx, ingestCancel := context.WithCancel(context.Background())
defer ingestCancel()
centrifugeLogHandler, logCloseFn := logging.Setup(cfg)
if logCloseFn != nil {
defer logCloseFn()
}
if cfgMeta.FileNotFound {
log.Warn().Msg("config file not found, continue using environment and flag options")
} else {
absConfPath, _ := filepath.Abs(configFile)
log.Info().Str("path", absConfPath).Msg("using config file")
if dotEnvUsed {
log.Info().Msg("environment variables have been loaded from .env file")
}
}
err = tools.WritePidFile(cfg.PidFile)
if err != nil {
log.Fatal().Err(err).Msg("error writing PID")
}
_, _ = maxprocs.Set(maxprocs.Logger(func(s string, i ...interface{}) {
log.Info().Msgf(strings.ToLower(s), i...)
}))
// Registered services will be run after node.Run() but before HTTP/GRPC servers start.
// Registered services will be stopped after node's shutdown and HTTP/GRPC servers shutdown.
serviceManager := service.NewManager()
entry := log.Info().
Str("version", build.Version).
Str("runtime", runtime.Version()).
Int("pid", os.Getpid()).
Int("gomaxprocs", runtime.GOMAXPROCS(0))
if fips140.Enabled() { // See https://go.dev/doc/security/fips140.
entry = entry.Bool("fips", true)
}
if cfg.Broker.Enabled {
entry = entry.Str("broker", cfg.Broker.Type)
}
if cfg.PresenceManager.Enabled {
entry = entry.Str("presence_manager", cfg.PresenceManager.Type)
}
if !cfg.Broker.Enabled || !cfg.PresenceManager.Enabled {
entry = entry.Str("engine", cfg.Engine.Type)
}
entry.Msg("starting Centrifugo")
if build.Version == "0.0.0" {
log.Warn().Msg("running a development build of Centrifugo (version 0.0.0), ensure to use release build in production")
}
err = cfg.Validate()
if err != nil {
log.Fatal().Err(err).Msg("error validating config")
}
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
log.Fatal().Err(err).Msg("error creating config")
}
cfgContainer.ChannelOptionsCacheTTL = 200 * time.Millisecond
// Initialize centralized metrics registry.
err = metrics.Init(metrics.Config{
Namespace: "", // Use default "centrifugo" namespace.
ConstLabels: nil, // Can be populated from config in the future.
Registerer: nil, // Use prometheus.DefaultRegisterer.
NativeHistograms: cfg.Prometheus.NativeHistograms,
})
if err != nil {
log.Fatal().Err(err).Msg("error initializing metrics")
}
proxyMap, keepHeadersInContext, err := buildProxyMap(cfg)
if err != nil {
log.Fatal().Err(err).Msg("error building proxy map")
}
nodeCfg := centrifugeNodeConfig(build.Version, edition, cfgContainer, centrifugeLogHandler)
node, err := centrifuge.New(nodeCfg)
if err != nil {
log.Fatal().Err(err).Msg("error creating Centrifuge Node")
}
if cfg.OpenTelemetry.Enabled {
_, err := telemetry.SetupTracing(context.Background(), cfg.OpenTelemetry, node.ID())
if err != nil {
log.Fatal().Err(err).Msg("error setting up opentelemetry tracing")
}
}
err = configureEngines(node, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("configure engines error")
}
err = configureMapBroker(node, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("configure map broker error")
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
log.Fatal().Err(err).Msg("error creating JWT verifier config")
}
tokenVerifier, err := jwtverify.NewTokenVerifierJWT(verifierConfig, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("error creating token verifier")
}
var subTokenVerifier *jwtverify.VerifierJWT
if cfg.Client.SubscriptionToken.Enabled {
log.Info().Msg("initializing separate verifier for subscription tokens")
subVerifier, err := confighelpers.MakeVerifierConfig(cfg.Client.SubscriptionToken.Token)
if err != nil {
log.Fatal().Err(err).Msg("error creating subscription JWT verifier config")
}
subTokenVerifier, err = jwtverify.NewTokenVerifierJWT(subVerifier, cfgContainer)
if err != nil {
log.Fatal().Err(err).Msg("error creating token verifier")
}
}
clientHandler := client.NewHandler(node, cfgContainer, tokenVerifier, subTokenVerifier, proxyMap)
err = clientHandler.Setup()
if err != nil {
log.Fatal().Err(err).Msg("error setting up client handler")
}
if cfg.RPC.Ping.Enabled {
log.Info().Str("method", cfg.RPC.Ping.Method).Msg("RPC ping extension enabled")
clientHandler.SetRPCExtension(cfg.RPC.Ping.Method, func(c client.Client, e centrifuge.RPCEvent) (centrifuge.RPCReply, error) {
return centrifuge.RPCReply{}, nil
})
}
surveyCaller := survey.NewCaller(node)
useAPIOpentelemetry := cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.API
useConsumingOpentelemetry := cfg.OpenTelemetry.Enabled && cfg.OpenTelemetry.Consuming
httpAPIExecutor := api.NewExecutor(node, cfgContainer, surveyCaller, api.ExecutorConfig{
Protocol: "http",
UseOpenTelemetry: useAPIOpentelemetry,
})
grpcAPIExecutor := api.NewExecutor(node, cfgContainer, surveyCaller, api.ExecutorConfig{
Protocol: "grpc",
UseOpenTelemetry: useAPIOpentelemetry,
})
consumingAPIExecutor := api.NewExecutor(node, cfgContainer, surveyCaller, api.ExecutorConfig{
Protocol: "consuming",
UseOpenTelemetry: useConsumingOpentelemetry,
})
consumingHandler := api.NewConsumingHandler(node, consumingAPIExecutor, api.ConsumingHandlerConfig{
UseOpenTelemetry: useConsumingOpentelemetry,
})
consumingServices, err := consuming.New(node.ID(), consumingHandler, cfg.Consumers)
if err != nil {
log.Fatal().Err(err).Msg("error initializing consumers")
}
// Consumers are registered in a separate manager: unlike other services they
// must be stopped before Node shutdown, so that no consumed message is
// acknowledged after this node lost the ability to deliver it.
ingestManager := service.NewManager()
ingestManager.Register(consumingServices...)
if cfg.Graphite.Enabled {
serviceManager.Register(graphiteExporter(cfg, nodeCfg))
}
var statsSender *usage.Sender
if !cfg.UsageStats.Disabled {
statsSender = usage.NewSender(node, cfgContainer, usage.Features{
Edition: edition,
Version: build.Version,
EngineEnabled: !cfg.Broker.Enabled || !cfg.PresenceManager.Enabled,
EngineType: cfg.Engine.Type,
BrokerEnabled: cfg.Broker.Enabled,
BrokerType: cfg.Broker.Type,
PresenceManagerEnabled: cfg.PresenceManager.Enabled,
PresenceManagerType: cfg.PresenceManager.Type,
Websocket: !cfg.WebSocket.Disabled,
HTTPStream: cfg.HTTPStream.Enabled,
SSE: cfg.SSE.Enabled,
UniWebsocket: cfg.UniWS.Enabled,
UniHTTPStream: cfg.UniHTTPStream.Enabled,
UniSSE: cfg.UniSSE.Enabled,
UniGRPC: cfg.UniGRPC.Enabled,
WebTransport: cfg.WebTransport.Enabled,
EnabledConsumers: usage.GetEnabledConsumers(cfg.Consumers),
GrpcAPI: cfg.GrpcAPI.Enabled,
SubscribeToPersonal: cfg.Client.SubscribeToUserPersonalChannel.Enabled,
Admin: cfg.Admin.Enabled,
ConnectProxy: proxyMap.ConnectProxy != nil,
RefreshProxy: proxyMap.RefreshProxy != nil,
SubscribeProxy: len(proxyMap.SubscribeProxies) > 0,
PublishProxy: len(proxyMap.PublishProxies) > 0,
RPCProxy: len(proxyMap.RpcProxies) > 0,
SubRefreshProxy: len(proxyMap.SubRefreshProxies) > 0,
SubscribeStreamProxy: len(proxyMap.SubscribeStreamProxies) > 0,
Swagger: cfg.Swagger.Enabled,
})
serviceManager.Register(statsSender)
}
notify.RegisterHandlers(node, statsSender)
if err = node.Run(); err != nil {
log.Fatal().Err(err).Msg("error running node")
}
serviceDone := runServices(ctx, serviceManager)
ingestDone := runServices(ingestCtx, ingestManager)
var grpcAPIServer *grpc.Server
if cfg.GrpcAPI.Enabled {
var err error
grpcAPIServer, err = runGRPCAPIServer(cfg, node, useAPIOpentelemetry, grpcAPIExecutor)
if err != nil {
log.Fatal().Err(err).Msg("error creating GRPC API server")
}
}
var grpcUniServer *grpc.Server
if cfg.UniGRPC.Enabled {
var err error
grpcUniServer, err = runGRPCUniServer(cfg, node)
if err != nil {
log.Fatal().Err(err).Msg("error creating uni GRPC server")
}
}
httpServers, err := runHTTPServers(node, cfgContainer, httpAPIExecutor, keepHeadersInContext)
if err != nil {
log.Fatal().Err(err).Msg("error running HTTP server")
}
logStartWarnings(cfg, cfgMeta)
handleSignals(
cmd, configFile, node, cfgContainer, tokenVerifier, subTokenVerifier,
httpServers, grpcAPIServer, grpcUniServer,
serviceDone, serviceCancel, ingestDone, ingestCancel,
)
}
// runServices runs all services registered in the manager and returns a channel
// closed when all of them stopped.
func runServices(ctx context.Context, manager *service.Manager) chan struct{} {
done := make(chan struct{})
manager.Run(ctx)
go func() {
defer close(done)
err := manager.Wait()
if err != nil && !errors.Is(err, context.Canceled) {
log.Error().Err(err).Msg("error running service")
os.Exit(1)
}
}()
return done
}
func handleSignals(
cmd *cobra.Command, configFile string, n *centrifuge.Node, cfgContainer *config.Container,
tokenVerifier *jwtverify.VerifierJWT, subTokenVerifier *jwtverify.VerifierJWT, httpServers []*http.Server,
grpcAPIServer *grpc.Server, grpcUniServer *grpc.Server, serviceDone chan struct{},
serviceCancel context.CancelFunc, ingestDone chan struct{}, ingestCancel context.CancelFunc,
) {
cfg := cfgContainer.Config()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, os.Interrupt, syscall.SIGTERM)
for {
sig := <-sigCh
log.Info().Msgf("signal received: %v", sig)
switch sig {
case syscall.SIGHUP:
// Reload application configuration on SIGHUP.
// Note that Centrifugo can't reload config for everything – just best effort to reload what's possible.
// We can now reload channel options and token verifiers.
log.Info().Msg("reloading configuration")
newCfg, _, err := config.GetConfig(cmd, configFile)
if err != nil {
log.Err(err).Msg("error reading config")
continue
}
if err = newCfg.Validate(); err != nil {
log.Error().Msgf("error validating config: %v", err)
continue
}
verifierConfig, err := confighelpers.MakeVerifierConfig(newCfg.Client.Token)
if err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
if err = tokenVerifier.Reload(verifierConfig); err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
if subTokenVerifier != nil {
subVerifierConfig, err := confighelpers.MakeVerifierConfig(newCfg.Client.SubscriptionToken.Token)
if err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
if err := subTokenVerifier.Reload(subVerifierConfig); err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
}
if err = cfgContainer.Reload(newCfg); err != nil {
log.Error().Msgf("error reloading: %v", err)
continue
}
log.Info().Msg("configuration successfully reloaded")
case syscall.SIGINT, os.Interrupt, syscall.SIGTERM:
log.Info().Msg("shutting down ...")
pidFile := cfg.PidFile
shutdownTimeout := cfg.Shutdown.Timeout
go time.AfterFunc(shutdownTimeout.ToDuration(), func() {
if pidFile != "" {
_ = os.Remove(pidFile)
}
log.Fatal().Msg("shutdown timeout reached")
})
var wg sync.WaitGroup
if grpcAPIServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
grpcAPIServer.GracefulStop()
}()
}
if grpcUniServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
grpcUniServer.GracefulStop()
}()
}
for _, srv := range httpServers {
wg.Add(1)
go func(srv *http.Server) {
defer wg.Done()
_ = srv.Shutdown(context.Background()) // We have a separate timeout goroutine.
}(srv)
}
// Stop consuming messages before Node shutdown and wait until consumers
// stopped. Otherwise a message consumed here may be acknowledged while
// this node is already unable to deliver it – such message must be left
// unacknowledged instead, to be redelivered upon restart. Note that we
// don't wait for HTTP/GRPC servers here: their shutdown only completes
// after Node disconnected clients, since long-lived streaming requests
// (SSE, HTTP-streaming, unidirectional GRPC) are in-flight until then.
ingestCancel()
<-ingestDone
_ = n.Shutdown(context.Background()) // We have a separate timeout goroutine.
wg.Wait()
serviceCancel()
<-serviceDone
if pidFile != "" {
_ = os.Remove(pidFile)
}
os.Exit(0)
}
}
}

81
internal/app/tls.go Normal file
View File

@@ -0,0 +1,81 @@
package app
import (
"crypto/tls"
stdlog "log"
"net/http"
"sync"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
var startHTTPChallengeServerOnce sync.Once
func GetTLSConfig(cfg config.Config) (*tls.Config, error) {
tlsEnabled := cfg.HTTP.TLS.Enabled
tlsAutocertEnabled := cfg.HTTP.TLSAutocert.Enabled
tlsAutocertHostWhitelist := cfg.HTTP.TLSAutocert.HostWhitelist
tlsAutocertCacheDir := cfg.HTTP.TLSAutocert.CacheDir
tlsAutocertEmail := cfg.HTTP.TLSAutocert.Email
tlsAutocertServerName := cfg.HTTP.TLSAutocert.ServerName
tlsAutocertHTTP := cfg.HTTP.TLSAutocert.HTTP
tlsAutocertHTTPAddr := cfg.HTTP.TLSAutocert.HTTPAddr
if tlsAutocertEnabled {
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: tlsAutocertEmail,
}
if tlsAutocertHostWhitelist != nil {
certManager.HostPolicy = autocert.HostWhitelist(tlsAutocertHostWhitelist...)
}
if tlsAutocertCacheDir != "" {
certManager.Cache = autocert.DirCache(tlsAutocertCacheDir)
}
if tlsAutocertHTTP {
startHTTPChallengeServerOnce.Do(func() {
// getTLSConfig can be called several times.
acmeHTTPServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: tlsAutocertHTTPAddr,
ErrorLog: stdlog.New(&httpErrorLogWriter{log.Logger}, "", 0),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
log.Info().Msgf("serving ACME http_01 challenge on %s", tlsAutocertHTTPAddr)
if err := acmeHTTPServer.ListenAndServe(); err != nil {
log.Fatal().Err(err).Msgf("can't create server on %s to serve acme http challenge", tlsAutocertHTTPAddr)
}
}()
})
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// See https://github.com/centrifugal/centrifugo/issues/144#issuecomment-279393819
if tlsAutocertServerName != "" && hello.ServerName == "" {
hello.ServerName = tlsAutocertServerName
}
return certManager.GetCertificate(hello)
},
NextProtos: []string{
"h2", "http/1.1", acme.ALPNProto,
},
}, nil
} else if tlsEnabled {
// Autocert disabled - just try to use provided SSL cert and key files.
return cfg.HTTP.TLS.ToGoTLSConfig("http_server")
}
return nil, nil
}

10
internal/build/build.go Normal file
View File

@@ -0,0 +1,10 @@
package build
// Version of server. Set to tag in CI during release.
var Version = "0.0.0"
// UsageStatsEndpoint is where we send stats. Set in CI during release.
var UsageStatsEndpoint string
// UsageStatsToken used for usage stats request auth. Set in CI during release.
var UsageStatsToken string

View File

@@ -0,0 +1,48 @@
package cli
import (
"fmt"
"os"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/spf13/cobra"
)
func CheckConfig() *cobra.Command {
var checkConfigFile string
var checkConfigStrict bool
var checkConfigCmd = &cobra.Command{
Use: "checkconfig",
Short: "Check configuration file",
Long: `Check Centrifugo configuration file`,
Run: func(cmd *cobra.Command, args []string) {
checkConfig(cmd, checkConfigFile, checkConfigStrict)
},
}
checkConfigCmd.Flags().StringVarP(&checkConfigFile, "config", "c", "config.json", "path to config file to check")
checkConfigCmd.Flags().BoolVarP(&checkConfigStrict, "strict", "s", false, "strict check - fail on unknown fields")
return checkConfigCmd
}
func checkConfig(cmd *cobra.Command, checkConfigFile string, strict bool) {
cfg, cfgMeta, err := config.GetConfig(cmd, checkConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
if cfgMeta.FileNotFound {
fmt.Println("config file not found")
os.Exit(1)
}
err = cfg.Validate()
if err != nil {
fmt.Printf("error validating config: %s\n", err)
os.Exit(1)
}
if strict && len(cfgMeta.UnknownKeys) > 0 {
fmt.Printf("unknown keys in config: %v\n", strings.Join(cfgMeta.UnknownKeys, ", "))
os.Exit(1)
}
}

View File

@@ -0,0 +1,95 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
)
func CheckSubToken() *cobra.Command {
var checkSubTokenConfigFile string
var checkSubTokenCmd = &cobra.Command{
Use: "checksubtoken [TOKEN]",
Short: "Check subscription JWT",
Long: `Check subscription JWT`,
Run: func(cmd *cobra.Command, args []string) {
checkSubToken(cmd, checkSubTokenConfigFile, args)
},
}
checkSubTokenCmd.Flags().StringVarP(&checkSubTokenConfigFile, "config", "c", "config.json", "path to config file")
return checkSubTokenCmd
}
func checkSubToken(cmd *cobra.Command, checkSubTokenConfigFile string, args []string) {
cfg, _, err := config.GetConfig(cmd, checkSubTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if cfg.Client.SubscriptionToken.Enabled {
verifierConfig, err = confighelpers.MakeVerifierConfig(cfg.Client.SubscriptionToken.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}
if len(args) != 1 {
fmt.Printf("error: provide token to check [centrifugo checksubtoken <TOKEN>]\n")
os.Exit(1)
}
subject, channel, claims, err := parseAndVerifySubToken(verifierConfig, cfg, args[0])
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user \"%s\"", subject)
if subject == "" {
user = "anonymous user"
}
fmt.Printf("valid subscription token for %s and channel \"%s\"\npayload: %s\n", user, channel, string(claims))
}
// parseAndVerifySubToken checks subscription JWT for user.
func parseAndVerifySubToken(config jwtverify.VerifierConfig, cfg config.Config, t string) (string, string, []byte, error) {
token, err := jwt.ParseNoVerify([]byte(t)) // Will be verified later.
if err != nil {
return "", "", nil, err
}
var claims jwtverify.SubscribeTokenClaims
err = json.Unmarshal(token.Claims(), &claims)
if err != nil {
return "", "", nil, err
}
ct, err := verifySub(config, cfg, t)
if err != nil {
return "", "", nil, fmt.Errorf("token with algorithm %s and claims %s has error: %v", token.Header().Algorithm, string(token.Claims()), err)
}
return ct.UserID, ct.Channel, token.Claims(), nil
}
func verifySub(verifierConf jwtverify.VerifierConfig, cfg config.Config, token string) (jwtverify.SubscribeToken, error) {
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
return jwtverify.SubscribeToken{}, err
}
verifier, err := jwtverify.NewTokenVerifierJWT(verifierConf, cfgContainer)
if err != nil {
return jwtverify.SubscribeToken{}, err
}
return verifier.VerifySubscribeToken(token, false)
}

View File

@@ -0,0 +1,88 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
)
func CheckToken() *cobra.Command {
var checkTokenConfigFile string
var checkTokenCmd = &cobra.Command{
Use: "checktoken [TOKEN]",
Short: "Check connection JWT",
Long: `Check connection JWT`,
Run: func(cmd *cobra.Command, args []string) {
checkToken(cmd, checkTokenConfigFile, args)
},
}
checkTokenCmd.Flags().StringVarP(&checkTokenConfigFile, "config", "c", "config.json", "path to config file")
return checkTokenCmd
}
func checkToken(cmd *cobra.Command, checkTokenConfigFile string, args []string) {
cfg, _, err := config.GetConfig(cmd, checkTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if len(args) != 1 {
fmt.Printf("error: provide token to check [centrifugo checktoken <TOKEN>]\n")
os.Exit(1)
}
subject, claims, err := parseAndVerifyToken(verifierConfig, cfg, args[0])
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user %s", subject)
if subject == "" {
user = "anonymous user"
}
fmt.Printf("valid token for %s\npayload: %s\n", user, string(claims))
}
// parseAndVerifyToken checks JWT for user.
func parseAndVerifyToken(config jwtverify.VerifierConfig, ruleConfig config.Config, t string) (string, []byte, error) {
token, err := jwt.ParseNoVerify([]byte(t)) // Will be verified later.
if err != nil {
return "", nil, err
}
claims := &jwt.RegisteredClaims{}
err = json.Unmarshal(token.Claims(), claims)
if err != nil {
return "", nil, err
}
ct, err := verify(config, ruleConfig, t)
if err != nil {
return "", nil, fmt.Errorf("token with algorithm %s and claims %s has error: %v", token.Header().Algorithm, string(token.Claims()), err)
}
return ct.UserID, token.Claims(), nil
}
func verify(verifierConf jwtverify.VerifierConfig, cfg config.Config, token string) (jwtverify.ConnectToken, error) {
cfgContainer, err := config.NewContainer(cfg)
if err != nil {
return jwtverify.ConnectToken{}, err
}
verifier, err := jwtverify.NewTokenVerifierJWT(verifierConf, cfgContainer)
if err != nil {
return jwtverify.ConnectToken{}, err
}
return verifier.VerifyConnectToken(token, false)
}

352
internal/cli/configdoc.go Normal file
View File

@@ -0,0 +1,352 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"reflect"
"regexp"
"strconv"
"strings"
_ "embed"
"github.com/centrifugal/centrifugo/v6/internal/build"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
)
//go:embed configdoc/template.html
var htmlTemplate string
func ConfigDoc() *cobra.Command {
var mdOutput bool
var section string
var port int
var baseLevel int
var configDocCmd = &cobra.Command{
Use: "configdoc",
Short: "Show Centrifugo configuration documentation generated from source code",
Long: `Show Centrifugo configuration documentation generated from source code`,
Run: func(cmd *cobra.Command, args []string) {
configDoc(port, mdOutput, section, baseLevel)
},
}
configDocCmd.Flags().BoolVarP(&mdOutput, "markdown", "m", false, "output markdown to stdout")
configDocCmd.Flags().StringVarP(&section, "section", "s", "", "filter by top-level section name")
configDocCmd.Flags().IntVarP(&port, "port", "p", 6060, "port to run server on")
configDocCmd.Flags().IntVarP(&baseLevel, "base-level", "l", 0, "base level to use")
return configDocCmd
}
func configDoc(port int, mdOutput bool, section string, baseLevel int) {
// Documentation is built from the config structs at runtime — descriptions
// come from the `doc` struct tags, types and defaults from reflection.
docs := documentStruct(config.Config{}, "", 1)
if mdOutput {
mdContent := convertDocsToMarkdown(docs, section, baseLevel, mdOutput)
fmt.Println(mdContent)
return
}
// Filter docs by section if specified
filteredDocs := docs
if section != "" {
filteredDocs = make([]FieldDoc, 0)
for _, doc := range docs {
if strings.HasPrefix(doc.Field, section) {
filteredDocs = append(filteredDocs, doc)
}
}
}
go func() {
url := "http://localhost:" + strconv.Itoa(port)
// OpenURL will open the default browser.
if err := browser.OpenURL(url); err != nil {
fmt.Printf("Failed to open browser automatically: %v, see %s\n", err, url)
}
}()
http.HandleFunc("/", makeMarkdownHandler(filteredDocs))
if err := http.ListenAndServe(":"+strconv.Itoa(port), nil); err != nil && !errors.Is(err, http.ErrServerClosed) { //nolint:gosec // Only for development use.
fmt.Println("Failed to start server:", err)
os.Exit(1)
}
}
// FieldDoc represents the JSON documentation for a configuration field.
type FieldDoc struct {
Field string `json:"field"`
Name string `json:"name"`
GoName string `json:"go_name"`
Level int `json:"level"`
Type string `json:"type"`
Default string `json:"default"`
Comment string `json:"comment"`
IsComplexType bool `json:"is_complex_type"`
Children []FieldDoc `json:"children,omitempty"`
}
// ConvertDocsToMarkdown recursively converts a slice of FieldDoc entries into Markdown.
func convertDocsToMarkdown(docs []FieldDoc, section string, baseLevel int, mdOutput bool) string {
var sb strings.Builder
for _, doc := range docs {
if section != "" && !strings.HasPrefix(doc.Field, section) {
continue
}
// Generate a header with the appropriate Markdown level.
fieldLevel := doc.Level + baseLevel
if fieldLevel > 6 {
fieldLevel = 6
}
header := strings.Repeat("#", fieldLevel)
typeDesc := fmt.Sprintf("Type: `%s`", doc.Type)
if doc.IsComplexType {
typeDesc = fmt.Sprintf("Type: `%s` object", doc.Type)
}
if doc.Default != "" {
typeDesc += fmt.Sprintf(". Default: `%s`", doc.Default)
}
var envVar string
if !strings.Contains(doc.Field, "[]") {
if !doc.IsComplexType {
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(doc.Field, `.`, "_"))
} else {
if strings.Contains(doc.Field, "namespaces") || strings.Contains(doc.Field, "proxies") || strings.Contains(doc.Field, "consumers") {
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(doc.Field, `.`, "_"))
} else if doc.Default == "[]" {
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(doc.Field, `.`, "_"))
}
}
} else {
if !strings.HasSuffix(doc.Field, ".name") &&
(strings.HasPrefix(doc.Field, "namespaces") ||
strings.HasPrefix(doc.Field, "proxies") ||
strings.HasPrefix(doc.Field, "consumers")) &&
(!doc.IsComplexType || doc.Default == "[]") {
field := strings.Replace(doc.Field, `[]`, "_<NAME>", 1)
envVar = "CENTRIFUGO_" + strings.ToUpper(strings.ReplaceAll(field, `.`, "_"))
}
if strings.Contains(envVar, "[]") {
envVar = ""
}
}
if envVar != "" {
typeDesc += fmt.Sprintf("\n\nEnv: `%s`", envVar)
}
comment := doc.Comment
if comment == "" {
comment = "No documentation available."
}
if strings.HasPrefix(comment, doc.GoName) {
comment = fmt.Sprintf("`%s`%s", doc.Name, comment[len(doc.GoName):])
}
field := fmt.Sprintf("`%s`", doc.Field)
if !mdOutput && doc.IsComplexType {
if doc.Default == "[]" {
field += " `[...]`"
} else {
field += " `{...}`"
}
}
sb.WriteString(fmt.Sprintf("%s %s\n\n%s\n\n%s\n\n", header, field, typeDesc, comment))
if len(doc.Children) > 0 {
sb.WriteString(convertDocsToMarkdown(doc.Children, section, baseLevel, mdOutput))
}
}
return sb.String()
}
func makeMarkdownHandler(docs []FieldDoc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if simple markdown view is requested
if r.URL.Query().Get("simple") == "1" {
serveSimpleMarkdownView(w, docs)
return
}
// Convert docs to JSON for JavaScript consumption
configJSON, err := json.Marshal(docs)
if err != nil {
http.Error(w, "Error marshaling config data: "+err.Error(), http.StatusInternalServerError)
return
}
// Parse the HTML template
tmpl, err := template.New("config").Parse(htmlTemplate)
if err != nil {
http.Error(w, "Error parsing template: "+err.Error(), http.StatusInternalServerError)
return
}
// Prepare template data
data := struct {
Version string
Content template.HTML
ConfigJSON template.JS
}{
Version: build.Version,
Content: "", // Content is now generated by JavaScript.
ConfigJSON: template.JS(configJSON), //nolint:gosec // Trusted data.
}
// Execute template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, "Error executing template: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
func serveSimpleMarkdownView(w http.ResponseWriter, docs []FieldDoc) {
mdContent := convertDocsToMarkdown(docs, "", 0, false)
var buf bytes.Buffer
if err := goldmark.Convert([]byte(mdContent), &buf); err != nil {
http.Error(w, "Error converting markdown: "+err.Error(), http.StatusInternalServerError)
return
}
simpleHTML := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Centrifugo %s Configuration Options (Simple View)</title>
<link href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/materia/bootstrap.min.css" rel="stylesheet">
<style>
body { padding: 2rem; }
.markdown-body { max-width: 1024px; margin: auto; }
code { color: #139f87; background-color: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }
.controls { text-align: center; margin-bottom: 2rem; }
</style>
</head>
<body>
<div class="container markdown-body">
<div class="controls">
<a href="/" class="btn btn-primary">Switch to Enhanced View</a>
</div>
%s
</div>
</body>
</html>`, build.Version, buf.String())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(simpleHTML))
}
var docCodeRe = regexp.MustCompile(`<<(.+?)>>`)
// convertDocCodes turns <<value>> markers in doc text into Markdown inline code.
func convertDocCodes(s string) string {
return docCodeRe.ReplaceAllString(s, "`$1`")
}
// getDisplayType returns the base type name and whether it's a complex (object)
// type. Both the type label and the complex/object classification are shared via
// configtypes so configdoc and the admin config UI (Pro) stay consistent.
func getDisplayType(t reflect.Type) (string, bool) {
return configtypes.TypeLabel(t), configtypes.IsComplexType(t)
}
// documentStruct walks a config struct via reflection and builds the field
// documentation tree at runtime — descriptions from the `doc` tag, types and
// defaults from reflection. Fields tagged `expose:"-"` are excluded.
func documentStruct(cfg interface{}, parentKey string, level int) []FieldDoc {
var docs []FieldDoc
t := reflect.TypeOf(cfg)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return docs
}
fieldLevel := level
if parentKey != "" {
fieldLevel = level + 1
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Tag.Get("json") == "-" {
continue
}
// Flatten squashed/embedded structs.
if configtypes.IsSquash(field) {
var nested interface{}
if field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct {
nested = reflect.New(field.Type.Elem()).Interface()
} else if field.Type.Kind() == reflect.Struct {
nested = reflect.New(field.Type).Interface()
}
if nested != nil {
docs = append(docs, documentStruct(nested, parentKey, level)...)
}
continue
}
if field.Tag.Get("expose") == "-" {
continue
}
keyTag := configtypes.JSONKey(field)
fullKey := keyTag
if parentKey != "" {
fullKey = parentKey + "." + keyTag
}
displayType, isComplex := getDisplayType(field.Type)
docEntry := FieldDoc{
Field: fullKey,
Name: keyTag,
GoName: field.Name,
Level: fieldLevel,
Type: displayType,
Default: field.Tag.Get("default"),
IsComplexType: isComplex,
Comment: convertDocCodes(field.Tag.Get("doc")),
}
var children []FieldDoc
switch field.Type.Kind() {
case reflect.Struct:
if field.Type.PkgPath() != "time" || field.Type.Name() != "Time" {
children = documentStruct(reflect.New(field.Type).Interface(), fullKey, fieldLevel)
}
case reflect.Pointer:
if field.Type.Elem().Kind() == reflect.Struct {
children = documentStruct(reflect.New(field.Type.Elem()).Interface(), fullKey, fieldLevel)
}
case reflect.Slice:
elem := field.Type.Elem()
if elem.Kind() == reflect.Pointer {
elem = elem.Elem()
}
if elem.Kind() == reflect.Struct {
children = documentStruct(reflect.New(elem).Interface(), fullKey+"[]", fieldLevel)
}
}
if len(children) > 0 {
docEntry.Children = children
}
docs = append(docs, docEntry)
}
return docs
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
func DefaultConfig() *cobra.Command {
var defaultConfigFile string
var baseConfigFile string
var dryRun bool
var defaultConfigCmd = &cobra.Command{
Use: "defaultconfig",
Short: "Generate full configuration file with defaults",
Long: `Generate full Centrifugo configuration file with defaults`,
Run: func(cmd *cobra.Command, args []string) {
defaultConfig(defaultConfigFile, baseConfigFile, dryRun)
},
}
defaultConfigCmd.Flags().StringVarP(&defaultConfigFile, "config", "c", "config.json", "path to default config file to generate")
defaultConfigCmd.Flags().StringVarP(&baseConfigFile, "base", "b", "", "path to the base config file to use")
defaultConfigCmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "dry run mode (do not write file and just print generated config to stdout)")
return defaultConfigCmd
}
func defaultConfig(configFile string, baseFile string, dryRun bool) {
if !dryRun {
exists, err := tools.PathExists(configFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if exists {
fmt.Printf("error: target file already exists\n")
os.Exit(1)
}
}
conf, _, err := config.GetConfig(nil, baseFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if err = conf.Validate(); err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var b []byte
ext := filepath.Ext(configFile)
if len(ext) > 1 {
ext = ext[1:]
}
supportedExtensions := []string{"json", "toml", "yaml", "yml"}
// This is an unreleased feature so we remove RedisNats from generated config.
conf.Broker.RedisNats = nil
jsonBytes, err := json.MarshalIndent(conf, "", " ")
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
switch ext {
case "json":
b = jsonBytes
case "toml":
b, err = toml.Marshal(conf)
case "yaml", "yml":
b, err = yaml.Marshal(conf)
default:
err = errors.New("output config file must have one of supported extensions: " + strings.Join(supportedExtensions, ", "))
}
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if dryRun {
fmt.Println(string(b))
return
}
err = os.WriteFile(configFile, b, 0600)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}

140
internal/cli/defaultenv.go Normal file
View File

@@ -0,0 +1,140 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/config/envconfig"
"github.com/spf13/cobra"
)
func DefaultEnv() *cobra.Command {
var baseConfigFile string
var baseNonZeroOnly bool
var defaultEnvCmd = &cobra.Command{
Use: "defaultenv",
Short: "Generate full environment var list with defaults",
Long: `Generate full Centrifugo environment var list with defaults`,
Run: func(cmd *cobra.Command, args []string) {
defaultEnv(baseConfigFile, baseNonZeroOnly)
},
}
defaultEnvCmd.Flags().StringVarP(&baseConfigFile, "base", "b", "", "path to the base config file to use")
defaultEnvCmd.Flags().BoolVarP(&baseNonZeroOnly, "base-non-zero-only", "", false, "only output environment variables for values which were non zero in base config file")
return defaultEnvCmd
}
func defaultEnv(baseFile string, baseNonZeroOnly bool) {
conf, meta, err := config.GetConfig(nil, baseFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if err = conf.Validate(); err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
printSortedEnvVars(meta.KnownEnvVars, baseNonZeroOnly)
}
var namedArrayEnvVars = []string{
"CENTRIFUGO_CHANNEL_NAMESPACES",
"CENTRIFUGO_RPC_NAMESPACES",
"CENTRIFUGO_CONSUMERS",
"CENTRIFUGO_PROXIES",
}
func printSortedEnvVars(knownEnvVars map[string]envconfig.VarInfo, baseNonZeroOnly bool) {
var envKeys []string
for env := range knownEnvVars {
envKeys = append(envKeys, env)
}
sort.Strings(envKeys)
for _, env := range envKeys {
if strings.HasSuffix(env, "-") {
// Hacky way to skip unnecessary struct field, can be based on struct tag.
continue
}
varInfo := knownEnvVars[env]
if baseNonZeroOnly && !varInfo.NonZeroInFile {
continue
}
value := valueToStringReflect(knownEnvVars[env].Field)
if slices.Contains(namedArrayEnvVars, env) { // Special handling for named array env vars.
var items []struct {
Name string `json:"name"`
}
s, err := strconv.Unquote(value)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
err = json.Unmarshal([]byte(s), &items)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
names := make([]string, len(items))
for i, item := range items {
names[i] = item.Name
}
value = strconv.Quote(strings.Join(names, " "))
}
fmt.Printf("%s=%s\n", env, value)
}
}
// valueToStringReflect converts a reflect.Value to a string in a way suitable for environment variables.
func valueToStringReflect(v reflect.Value) string {
switch v.Kind() {
case reflect.Slice, reflect.Array:
// Check if the element type of the slice/array is a struct
if v.Type().Elem().Kind() == reflect.Struct {
// Marshal the entire array/slice of structs to JSON
jsonValue, err := json.Marshal(v.Interface())
if err != nil {
panic(err)
}
// Escape double quotes to make the value suitable for environment variables
return fmt.Sprintf("\"%v\"", strings.ReplaceAll(string(jsonValue), `"`, `\"`))
}
var elements []string
for i := 0; i < v.Len(); i++ {
elements = append(elements, valueToStringReflect(v.Index(i)))
}
if len(elements) == 0 {
return "\"\""
}
return fmt.Sprintf("%v", strings.Join(elements, " "))
case reflect.Struct:
// You can customize how structs should be serialized if needed
return fmt.Sprintf("%v", v.Interface()) // Fallback to default formatting (customize if necessary)
case reflect.Map:
jsonValue, err := json.Marshal(v.Interface())
if err != nil {
panic(err)
}
// Escape double quotes to make the value suitable for environment variables
return fmt.Sprintf("\"%v\"", strings.ReplaceAll(string(jsonValue), `"`, `\"`))
case reflect.Pointer:
if v.IsNil() {
return ""
}
return valueToStringReflect(v.Elem()) // Dereference the pointer and recursively process
case reflect.Invalid:
return "" // Handle zero/nil values
case reflect.String:
return fmt.Sprintf("\"%v\"", v.Interface())
default:
// Fallback for other types (int, bool, etc.)
return fmt.Sprintf("%v", v.Interface())
}
}

45
internal/cli/genconfig.go Normal file
View File

@@ -0,0 +1,45 @@
package cli
import (
"fmt"
"os"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/spf13/cobra"
)
func GenConfig() *cobra.Command {
var outputConfigFile string
var genConfigCmd = &cobra.Command{
Use: "genconfig",
Short: "Generate minimal configuration file to start with",
Long: `Generate minimal configuration file to start with`,
Run: func(cmd *cobra.Command, args []string) {
genConfig(cmd, outputConfigFile)
},
}
genConfigCmd.Flags().StringVarP(&outputConfigFile, "config", "c", "config.json", "path to output config file")
return genConfigCmd
}
func genConfig(cmd *cobra.Command, outputConfigFile string) {
err := tools.GenerateConfig(outputConfigFile)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
cfg, _, err := config.GetConfig(cmd, outputConfigFile)
if err != nil {
_ = os.Remove(outputConfigFile)
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
err = cfg.Validate()
if err != nil {
_ = os.Remove(outputConfigFile)
fmt.Printf("error validating config: %v\n", err)
os.Exit(1)
}
}

123
internal/cli/gensubtoken.go Normal file
View File

@@ -0,0 +1,123 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
"github.com/tidwall/sjson"
)
func GenSubToken() *cobra.Command {
var genSubTokenConfigFile string
var genSubTokenUser string
var genSubTokenChannel string
var genSubTokenTTL int64
var genSubTokenQuiet bool
var genSubTokenCmd = &cobra.Command{
Use: "gensubtoken",
Short: "Generate sample subscription JWT for user",
Long: `Generate sample subscription JWT for user`,
Run: func(cmd *cobra.Command, args []string) {
genSubToken(cmd, genSubTokenConfigFile, genSubTokenUser, genSubTokenChannel, genSubTokenTTL, genSubTokenQuiet)
},
}
genSubTokenCmd.Flags().StringVarP(&genSubTokenConfigFile, "config", "c", "config.json", "path to config file")
genSubTokenCmd.Flags().StringVarP(&genSubTokenUser, "user", "u", "", "user ID")
genSubTokenCmd.Flags().StringVarP(&genSubTokenChannel, "channel", "s", "", "channel")
genSubTokenCmd.Flags().Int64VarP(&genSubTokenTTL, "ttl", "t", 3600*24*7, "token TTL in seconds, use -1 for token without expiration")
genSubTokenCmd.Flags().BoolVarP(&genSubTokenQuiet, "quiet", "q", false, "only output the token without anything else")
return genSubTokenCmd
}
func genSubToken(
cmd *cobra.Command, genSubTokenConfigFile string, genSubTokenUser string,
genSubTokenChannel string, genSubTokenTTL int64, genSubTokenQuiet bool,
) {
cfg, _, err := config.GetConfig(cmd, genSubTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
if genSubTokenChannel == "" {
fmt.Println("channel is required")
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if cfg.Client.SubscriptionToken.Enabled {
verifierConfig, err = confighelpers.MakeVerifierConfig(cfg.Client.SubscriptionToken.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}
token, err := generateSubToken(verifierConfig, genSubTokenUser, genSubTokenChannel, genSubTokenTTL)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user \"%s\"", genSubTokenUser)
if genSubTokenUser == "" {
user = "anonymous user"
}
exp := "without expiration"
if genSubTokenTTL >= 0 {
exp = fmt.Sprintf("with expiration TTL %s", time.Duration(genSubTokenTTL)*time.Second)
}
if genSubTokenQuiet {
fmt.Print(token)
return
}
fmt.Printf("HMAC SHA-256 JWT for %s and channel \"%s\" %s:\n%s\n", user, genSubTokenChannel, exp, token)
}
// generateSubToken generates sample subscription JWT for user.
func generateSubToken(config jwtverify.VerifierConfig, user string, channel string, ttlSeconds int64) (string, error) {
if config.HMACSecretKey == "" {
return "", errors.New("gensubtoken only supports generating HMAC-signed tokens, but no HMAC secret key is set in the configuration (client.subscription_token.token.hmac_secret_key, or client.token.hmac_secret_key when subscription token is not separately configured) – if you use RSA/ECDSA/JWKS token verification you need to generate the JWT with your own tooling")
}
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(config.HMACSecretKey))
if err != nil {
return "", fmt.Errorf("error creating HMAC signer: %w", err)
}
builder := jwt.NewBuilder(signer)
claims := jwtverify.SubscribeTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user,
},
Channel: channel,
}
if ttlSeconds > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second))
}
encodedClaims, err := json.Marshal(claims)
if err != nil {
return "", err
}
if config.UserIDClaim != "" {
encodedClaims, err = sjson.SetBytes(encodedClaims, config.UserIDClaim, user)
if err != nil {
return "", err
}
}
token, err := builder.Build(encodedClaims)
if err != nil {
return "", err
}
return token.String(), nil
}

106
internal/cli/gentoken.go Normal file
View File

@@ -0,0 +1,106 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/centrifugal/centrifugo/v6/internal/config"
"github.com/centrifugal/centrifugo/v6/internal/confighelpers"
"github.com/centrifugal/centrifugo/v6/internal/jwtverify"
"github.com/cristalhq/jwt/v5"
"github.com/spf13/cobra"
"github.com/tidwall/sjson"
)
func GenToken() *cobra.Command {
var genTokenConfigFile string
var genTokenUser string
var genTokenTTL int64
var genTokenQuiet bool
var genTokenCmd = &cobra.Command{
Use: "gentoken",
Short: "Generate sample connection JWT for user",
Long: `Generate sample connection JWT for user`,
Run: func(cmd *cobra.Command, args []string) {
genToken(cmd, genTokenConfigFile, genTokenUser, genTokenTTL, genTokenQuiet)
},
}
genTokenCmd.Flags().StringVarP(&genTokenConfigFile, "config", "c", "config.json", "path to config file")
genTokenCmd.Flags().StringVarP(&genTokenUser, "user", "u", "", "user ID, by default anonymous")
genTokenCmd.Flags().Int64VarP(&genTokenTTL, "ttl", "t", 3600*24*7, "token TTL in seconds, use -1 for token without expiration")
genTokenCmd.Flags().BoolVarP(&genTokenQuiet, "quiet", "q", false, "only output the token without anything else")
return genTokenCmd
}
func genToken(cmd *cobra.Command, genTokenConfigFile string, genTokenUser string, genTokenTTL int64, genTokenQuiet bool) {
cfg, _, err := config.GetConfig(cmd, genTokenConfigFile)
if err != nil {
fmt.Printf("error getting config: %v\n", err)
os.Exit(1)
}
verifierConfig, err := confighelpers.MakeVerifierConfig(cfg.Client.Token)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
token, err := generateToken(verifierConfig, genTokenUser, genTokenTTL)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
var user = fmt.Sprintf("user \"%s\"", genTokenUser)
if genTokenUser == "" {
user = "anonymous user"
}
exp := "without expiration"
if genTokenTTL >= 0 {
exp = fmt.Sprintf("with expiration TTL %s", time.Duration(genTokenTTL)*time.Second)
}
if genTokenQuiet {
fmt.Print(token)
return
}
fmt.Printf("HMAC SHA-256 JWT for %s %s:\n%s\n", user, exp, token)
}
// generateToken generates sample JWT for user.
func generateToken(config jwtverify.VerifierConfig, user string, ttlSeconds int64) (string, error) {
if config.HMACSecretKey == "" {
return "", errors.New("gentoken only supports generating HMAC-signed tokens, but no HMAC secret key is set in the configuration (client.token.hmac_secret_key) – if you use RSA/ECDSA/JWKS token verification you need to generate the JWT with your own tooling")
}
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(config.HMACSecretKey))
if err != nil {
return "", fmt.Errorf("error creating HMAC signer: %w", err)
}
builder := jwt.NewBuilder(signer)
claims := jwtverify.ConnectTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user,
},
}
if ttlSeconds > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second))
}
encodedClaims, err := json.Marshal(claims)
if err != nil {
return "", err
}
if config.UserIDClaim != "" {
encodedClaims, err = sjson.SetBytes(encodedClaims, config.UserIDClaim, user)
if err != nil {
return "", err
}
}
token, err := builder.Build(encodedClaims)
if err != nil {
return "", err
}
return token.String(), nil
}

38
internal/cli/serve.go Normal file
View File

@@ -0,0 +1,38 @@
package cli
import (
"fmt"
"net"
"net/http"
"os"
"strconv"
"github.com/spf13/cobra"
)
func Serve() *cobra.Command {
var serveDir string
var servePort int
var serveAddr string
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Run static file server (for development only)",
Long: `Run static file server (for development only)`,
Run: func(cmd *cobra.Command, args []string) {
serve(serveAddr, servePort, serveDir)
},
}
serveCmd.Flags().StringVarP(&serveDir, "dir", "d", "./", "path to directory")
serveCmd.Flags().IntVarP(&servePort, "port", "p", 3000, "port to serve on")
serveCmd.Flags().StringVarP(&serveAddr, "address", "a", "", "interface to serve on (default: all interfaces)")
return serveCmd
}
func serve(serveAddr string, servePort int, serveDir string) {
address := net.JoinHostPort(serveAddr, strconv.Itoa(servePort))
fmt.Printf("start serving %s on %s\n", serveDir, address)
if err := http.ListenAndServe(address, http.FileServer(http.Dir(serveDir))); err != nil { //nolint:gosec // Only for development use.
fmt.Println(err)
os.Exit(1)
}
}

25
internal/cli/version.go Normal file
View File

@@ -0,0 +1,25 @@
package cli
import (
"fmt"
"runtime"
"github.com/centrifugal/centrifugo/v6/internal/build"
"github.com/spf13/cobra"
)
func Version() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Centrifugo version information",
Long: `Print the version information of Centrifugo`,
Run: func(cmd *cobra.Command, args []string) {
version()
},
}
}
func version() {
fmt.Printf("Centrifugo v%s (Go version: %s)\n", build.Version, runtime.Version())
}

19
internal/client/client.go Normal file
View File

@@ -0,0 +1,19 @@
package client
import (
"context"
"github.com/centrifugal/centrifuge"
)
// Client represents client connection.
type Client interface {
ID() string
UserID() string
IsSubscribed(string) bool
Context() context.Context
Transport() centrifuge.TransportInfo
AcquireStorage() (map[string]any, func(map[string]any))
Unsubscribe(ch string, unsubscribe ...centrifuge.Unsubscribe)
WritePublication(channel string, publication *centrifuge.Publication, sp centrifuge.StreamPosition) error
}

1332
internal/client/handler.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
package client
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"hash"
"strconv"
"strings"
"sync"
)
const gracePeriodSeconds = 5
func parseSignatureTimestamps(sig string) (iat int64, expiry int64) {
first := strings.IndexByte(sig, ':')
if first < 0 {
return 0, 0
}
rest := sig[first+1:]
second := strings.IndexByte(rest, ':')
if second < 0 {
return 0, 0
}
iatVal, err := strconv.ParseInt(sig[:first], 10, 64)
if err != nil {
return 0, 0
}
expiryVal, err := strconv.ParseInt(rest[:second], 10, 64)
if err != nil {
return 0, 0
}
return iatVal, expiryVal
}
// trackSignatureVerifier reuses HMAC hashers and buffers for signature verification.
// Thread-safe via sync.Pool.
type trackSignatureVerifier struct {
macPool sync.Pool
bufPool sync.Pool
}
type verifyBufs struct {
payload []byte
hexBuf []byte
expectedHex []byte
}
func newTrackSignatureVerifier(secret string) *trackSignatureVerifier {
return &trackSignatureVerifier{
macPool: sync.Pool{
New: func() any {
return hmac.New(sha256.New, []byte(secret))
},
},
bufPool: sync.Pool{
New: func() any {
return &verifyBufs{
payload: make([]byte, 0, 256),
hexBuf: make([]byte, hex.EncodedLen(sha256.Size)),
expectedHex: make([]byte, hex.EncodedLen(sha256.Size)),
}
},
},
}
}
func verifyTrackSignature(secret string, channel string, sig string, keys []string, userID string) bool {
return newTrackSignatureVerifier(secret).verify(channel, sig, keys, userID)
}
func (v *trackSignatureVerifier) verify(channel string, sig string, keys []string, userID string) bool {
first := strings.IndexByte(sig, ':')
if first < 0 {
return false
}
rest := sig[first+1:]
second := strings.IndexByte(rest, ':')
if second < 0 {
return false
}
iatStr := sig[:first]
expiryStr := rest[:second]
hmacHex := rest[second+1:]
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
mac := v.macPool.Get().(hash.Hash)
bufs := v.bufPool.Get().(*verifyBufs)
// Payload fields are NUL-separated rather than ':'-separated so colons
// inside userID or channel (e.g. "news:tech", "alice:org") can't shift
// between fields and yield a colliding HMAC for a different (userID,
// channel) tuple. The outer signature string itself stays ':'-separated
// because its fields (iat / exp / hmac_hex) are colon-free by construction.
mac.Reset()
bufs.payload = bufs.payload[:0]
bufs.payload = append(bufs.payload, iatStr...)
bufs.payload = append(bufs.payload, 0)
bufs.payload = append(bufs.payload, expiryStr...)
bufs.payload = append(bufs.payload, 0)
bufs.payload = append(bufs.payload, userID...)
bufs.payload = append(bufs.payload, 0)
bufs.payload = append(bufs.payload, channel...)
bufs.payload = append(bufs.payload, 0)
hex.Encode(bufs.hexBuf, keysHash[:])
bufs.payload = append(bufs.payload, bufs.hexBuf...)
mac.Write(bufs.payload)
hex.Encode(bufs.expectedHex, mac.Sum(nil))
result := hmac.Equal([]byte(hmacHex), bufs.expectedHex)
v.macPool.Put(mac)
v.bufPool.Put(bufs)
return result
}

View File

@@ -0,0 +1,332 @@
package client
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"testing"
"time"
"github.com/cristalhq/jwt/v5"
sjson "github.com/segmentio/encoding/json"
)
func generateKeys(n int) []string {
keys := make([]string, n)
for i := range keys {
keys[i] = fmt.Sprintf("item_%05d", i)
}
return keys
}
// HMAC signature: create.
func createTrackSignature(secret, channel string, keys []string, userID string, iat, expiry int64) string {
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Inner payload uses NUL separators; outer signature stays ':'-separated.
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", iat, expiry, userID, channel, keysHash)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return fmt.Sprintf("%d:%d:%x", iat, expiry, mac.Sum(nil))
}
// JWT: claims struct that includes keys.
type trackClaims struct {
jwt.RegisteredClaims
Channel string `json:"channel"`
Keys []string `json:"keys"`
}
func BenchmarkTrackSignature_HMAC_Create_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
b.ResetTimer()
for i := 0; i < b.N; i++ {
createTrackSignature(secret, channel, keys, userID, now, expiry)
}
}
func BenchmarkTrackSignature_HMAC_Create_Reuse_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
// Pre-compute keys hash.
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Reuse HMAC hasher and buffers.
mac := hmac.New(sha256.New, []byte(secret))
payload := make([]byte, 0, 256)
result := make([]byte, 0, 128)
hexBuf := make([]byte, hex.EncodedLen(sha256.Size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
mac.Reset()
// Inner payload uses NUL separators; outer signature stays ':'-separated.
payload = payload[:0]
payload = strconv.AppendInt(payload, now, 10)
payload = append(payload, 0)
payload = strconv.AppendInt(payload, expiry, 10)
payload = append(payload, 0)
payload = append(payload, userID...)
payload = append(payload, 0)
payload = append(payload, channel...)
payload = append(payload, 0)
hex.Encode(hexBuf, keysHash[:])
payload = append(payload, hexBuf...)
mac.Write(payload)
result = result[:0]
result = strconv.AppendInt(result, now, 10)
result = append(result, ':')
result = strconv.AppendInt(result, expiry, 10)
result = append(result, ':')
hex.Encode(hexBuf, mac.Sum(nil))
result = append(result, hexBuf...)
_ = string(result)
}
}
func BenchmarkTrackSignature_HMAC_Verify_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
sig := createTrackSignature(secret, channel, keys, userID, now, expiry)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if !verifyTrackSignature(secret, channel, sig, keys, userID) {
b.Fatal("verification failed")
}
}
}
func BenchmarkTrackSignature_HMAC_Verify_Reuse_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now().Unix()
expiry := now + 3600
sig := createTrackSignature(secret, channel, keys, userID, now, expiry)
// Pre-compute keys hash.
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Reuse HMAC hasher and buffers.
mac := hmac.New(sha256.New, []byte(secret))
payload := make([]byte, 0, 256)
hexBuf := make([]byte, hex.EncodedLen(sha256.Size))
expectedHex := make([]byte, hex.EncodedLen(sha256.Size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Parse iat:expiry:hmac from signature.
first := strings.IndexByte(sig, ':')
rest := sig[first+1:]
second := strings.IndexByte(rest, ':')
iatStr := sig[:first]
expiryStr := rest[:second]
hmacHex := rest[second+1:]
mac.Reset()
// Inner payload uses NUL separators; outer signature parsed with ':' above.
payload = payload[:0]
payload = append(payload, iatStr...)
payload = append(payload, 0)
payload = append(payload, expiryStr...)
payload = append(payload, 0)
payload = append(payload, userID...)
payload = append(payload, 0)
payload = append(payload, channel...)
payload = append(payload, 0)
hex.Encode(hexBuf, keysHash[:])
payload = append(payload, hexBuf...)
mac.Write(payload)
hex.Encode(expectedHex, mac.Sum(nil))
if !hmac.Equal([]byte(hmacHex), expectedHex) {
b.Fatal("verification failed")
}
}
}
func BenchmarkTrackSignature_JWT_Create_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := builder.Build(claims)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTrackSignature_JWT_Create_Segmentio_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
rawClaims, err := sjson.Marshal(claims)
if err != nil {
b.Fatal(err)
}
_, err = builder.Build(rawClaims)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTrackSignature_JWT_Verify_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
token, err := builder.Build(claims)
if err != nil {
b.Fatal(err)
}
tokenBytes := token.Bytes()
verifier, err := jwt.NewVerifierHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
parsedToken, err := jwt.Parse(tokenBytes, verifier)
if err != nil {
b.Fatal(err)
}
var parsed trackClaims
if err := parsedToken.DecodeClaims(&parsed); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTrackSignature_JWT_Verify_Segmentio_10kKeys(b *testing.B) {
keys := generateKeys(10_000)
secret := "bench-secret-key"
channel := "post_votes:feed1"
userID := "user123"
now := time.Now()
expiry := now.Add(time.Hour)
signer, err := jwt.NewSignerHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
builder := jwt.NewBuilder(signer)
claims := &trackClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiry),
},
Channel: channel,
Keys: keys,
}
token, err := builder.Build(claims)
if err != nil {
b.Fatal(err)
}
tokenBytes := token.Bytes()
verifier, err := jwt.NewVerifierHS(jwt.HS256, []byte(secret))
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
parsedToken, err := jwt.Parse(tokenBytes, verifier)
if err != nil {
b.Fatal(err)
}
var parsed trackClaims
if _, err := sjson.Parse(parsedToken.Claims(), &parsed, sjson.ZeroCopy); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,229 @@
package client
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
const testSecret = "test-secret-key"
func makeTestSignature(secret, channel string, keys []string, userID string, iat, expiry int64) string {
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
// Inner payload uses NUL separators so colons in userID/channel can't
// shift fields. Outer signature stays ':'-separated (colon-free fields).
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", iat, expiry, userID, channel, keysHash)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return fmt.Sprintf("%d:%d:%x", iat, expiry, mac.Sum(nil))
}
func TestTrackSignature_ValidHMAC(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1", "key2"}, "user1", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1", "key2"}, "user1"))
}
func TestTrackSignature_InvalidHMAC(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1", "key2"}, "user1", now, expiry)
// Tamper with the signature by flipping its last hex character to a
// different one. Replacing it with a fixed value would be a no-op when the
// signature already happens to end in that value (~1/256 of runs), which made
// this test flaky.
repl := byte('f')
if sig[len(sig)-1] == 'f' {
repl = 'e'
}
sig = sig[:len(sig)-1] + string(repl)
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1", "key2"}, "user1"))
}
func TestTrackSignature_ExpiredSignature(t *testing.T) {
now := time.Now().Unix()
iat := now - 7200
expiry := now - 3600 // Expired 1 hour ago.
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", iat, expiry)
// Signature is valid HMAC-wise but the caller (OnTrack) would check expiry.
// verifyTrackSignature itself only checks HMAC, not expiry.
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, "user1"))
// But parseSignatureTimestamps should return the expired value.
_, parsedExpiry := parseSignatureTimestamps(sig)
require.True(t, parsedExpiry < now)
}
func TestTrackSignature_DifferentKeys(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"keyA", "keyB"}, "user1", now, expiry)
// Client sends different keys — should fail.
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"keyA", "keyC"}, "user1"))
}
func TestTrackSignature_DifferentUser(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", now, expiry)
// Different user — should fail.
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, "user2"))
}
func TestTrackSignature_AnonymousUser(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, ""))
}
func TestTrackSignature_KeyOrderSensitive(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
// Sign with keys [B, A].
sig := makeTestSignature(testSecret, "test:channel", []string{"keyB", "keyA"}, "user1", now, expiry)
// Verify with keys in the same order — succeeds.
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"keyB", "keyA"}, "user1"))
// Verify with keys in a different order — fails. Signature is bound to wire order, not set membership.
require.False(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"keyA", "keyB"}, "user1"))
}
func TestTrackSignature_EmptySignature(t *testing.T) {
require.False(t, verifyTrackSignature(testSecret, "test:channel", "", []string{"key1"}, "user1"))
}
func TestTrackSignature_MalformedSignature(t *testing.T) {
require.False(t, verifyTrackSignature(testSecret, "test:channel", "not-a-valid-sig", []string{"key1"}, "user1"))
require.False(t, verifyTrackSignature(testSecret, "test:channel", "123", []string{"key1"}, "user1"))
}
func TestTrackSignature_WrongSecret(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", now, expiry)
require.False(t, verifyTrackSignature("wrong-secret", "test:channel", sig, []string{"key1"}, "user1"))
}
func TestTrackSignature_WrongChannel(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", now, expiry)
require.False(t, verifyTrackSignature(testSecret, "other:channel", sig, []string{"key1"}, "user1"))
}
func TestParseSignatureTimestamps(t *testing.T) {
iat, expiry := parseSignatureTimestamps("1000:2000:abcdef")
require.Equal(t, int64(1000), iat)
require.Equal(t, int64(2000), expiry)
}
func TestParseSignatureTimestamps_Invalid(t *testing.T) {
tests := []string{
"",
"abc",
"1000",
"1000:",
"1000:abc:def",
"abc:2000:def",
}
for _, sig := range tests {
iat, expiry := parseSignatureTimestamps(sig)
require.Equal(t, int64(0), iat, "sig=%q", sig)
require.Equal(t, int64(0), expiry, "sig=%q", sig)
}
}
func TestTrackSignature_SingleKey(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "ch", []string{"only-key"}, "u", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "ch", sig, []string{"only-key"}, "u"))
}
func TestTrackSignature_ManyKeys(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
keys := make([]string, 100)
for i := range keys {
keys[i] = fmt.Sprintf("key-%03d", i)
}
sig := makeTestSignature(testSecret, "ch", keys, "user1", now, expiry)
require.True(t, verifyTrackSignature(testSecret, "ch", sig, keys, "user1"))
}
func TestParseSignatureTimestamps_RealSignature(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
sig := makeTestSignature(testSecret, "ch", []string{"k"}, "u", now, expiry)
parsedIat, parsedExpiry := parseSignatureTimestamps(sig)
require.Equal(t, now, parsedIat)
require.Equal(t, expiry, parsedExpiry)
}
func TestTrackSignature_GracePeriod(t *testing.T) {
now := time.Now().Unix()
// Expired 3 seconds ago — within 5-second grace period.
iat := now - 3600
expiry := now - 3
sig := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", iat, expiry)
// HMAC is valid (verifyTrackSignature doesn't check expiry).
require.True(t, verifyTrackSignature(testSecret, "test:channel", sig, []string{"key1"}, "user1"))
// The caller would check: expiry + gracePeriodSeconds >= now
_, parsedExpiry := parseSignatureTimestamps(sig)
withinGracePeriod := parsedExpiry+gracePeriodSeconds >= now
require.True(t, withinGracePeriod)
// Outside grace period.
iat2 := now - 7200
expiry2 := now - 60 // Expired 60 seconds ago, past grace period.
sig2 := makeTestSignature(testSecret, "test:channel", []string{"key1"}, "user1", iat2, expiry2)
_, parsedExpiry2 := parseSignatureTimestamps(sig2)
withinGracePeriod2 := parsedExpiry2+gracePeriodSeconds >= now
require.False(t, withinGracePeriod2)
}
func TestVerifyTrackSignature_HexEncoding(t *testing.T) {
// Verify that the expected hex encoding matches between make and verify.
now := time.Now().Unix()
expiry := now + 3600
keys := []string{"key1"}
keysHash := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", now, expiry, "u", "ch", keysHash)
mac := hmac.New(sha256.New, []byte(testSecret))
mac.Write([]byte(payload))
expectedHex := hex.EncodeToString(mac.Sum(nil))
sig := fmt.Sprintf("%d:%d:%s", now, expiry, expectedHex)
require.True(t, verifyTrackSignature(testSecret, "ch", sig, keys, "u"))
}
// TestTrackSignature_ColonNoAmbiguity is a regression test for the previous
// payload format that used ':' between every field. Under that format, a
// signature issued for (user="alice", channel="news:tech") and one issued
// for (user="alice:news", channel="tech") produced byte-identical payloads
// and therefore interchangeable signatures. NUL separation eliminates this.
func TestTrackSignature_ColonNoAmbiguity(t *testing.T) {
now := time.Now().Unix()
expiry := now + 3600
keys := []string{"key1"}
sigA := makeTestSignature(testSecret, "news:tech", keys, "alice", now, expiry)
sigB := makeTestSignature(testSecret, "tech", keys, "alice:news", now, expiry)
require.NotEqual(t, sigA, sigB, "colon-overlap signatures must differ under NUL-separated payload")
// Signature A must NOT verify against B's (user, channel) tuple — proves
// the two are independent under the new format.
require.False(t, verifyTrackSignature(testSecret, "tech", sigA, keys, "alice:news"))
// And vice versa.
require.False(t, verifyTrackSignature(testSecret, "news:tech", sigB, keys, "alice"))
}

View File

@@ -0,0 +1 @@
package clientcontext

View File

@@ -0,0 +1,24 @@
package clientcontext
import (
"context"
)
type emulatedHeadersKey struct{}
// GetEmulatedHeadersFromContext returns emulated headers from context.
func GetEmulatedHeadersFromContext(ctx context.Context) (map[string]string, bool) {
if val := ctx.Value(emulatedHeadersKey{}); val != nil {
values, ok := val.(map[string]string)
return values, ok
}
return nil, false
}
// SetEmulatedHeadersToContext sets header map to context.
func SetEmulatedHeadersToContext(ctx context.Context, h map[string]string) context.Context {
if len(h) == 0 {
return ctx
}
return context.WithValue(ctx, emulatedHeadersKey{}, h)
}

View File

@@ -0,0 +1,5 @@
package clientstorage
const (
KeyMeta = "meta"
)

73
internal/config/cache.go Normal file
View File

@@ -0,0 +1,73 @@
package config
import (
"hash/fnv"
"sync/atomic"
"time"
)
type cacheItem struct {
channel string
value channelOptionsResult
expires int64
}
type cacheShard struct {
// index is unsigned on purpose: it only ever grows, and unsigned overflow
// wraps to zero cleanly. With a signed counter the wrap goes negative and
// the modulo below yields a negative buffer index, panicking the server
// after 2^31 Set calls land on the same shard.
index atomic.Uint32
size uint32
buffer []atomic.Value
}
type rollingCache struct {
shards []*cacheShard
}
func newRollingCache(shardSize int, shardCount int) *rollingCache {
rc := &rollingCache{
shards: make([]*cacheShard, shardCount),
}
for i := range rc.shards {
shard := &cacheShard{
size: uint32(shardSize),
buffer: make([]atomic.Value, shardSize),
}
for j := 0; j < shardSize; j++ {
shard.buffer[j].Store(&cacheItem{}) // Initialize with zero value.
}
rc.shards[i] = shard
}
return rc
}
func (c *rollingCache) shardForKey(key string) *cacheShard {
h := fnv.New64a()
_, _ = h.Write([]byte(key))
shardIndex := h.Sum64() % uint64(len(c.shards))
return c.shards[shardIndex]
}
func (c *rollingCache) Get(channel string) (channelOptionsResult, bool) {
shard := c.shardForKey(channel)
for i := 0; i < int(shard.size); i++ {
item := shard.buffer[i].Load().(*cacheItem)
if item.channel == channel && time.Now().UnixNano() < item.expires {
return item.value, true
}
}
return channelOptionsResult{}, false
}
func (c *rollingCache) Set(channel string, value channelOptionsResult, ttl time.Duration) {
shard := c.shardForKey(channel)
index := int(shard.index.Add(1) % shard.size)
item := &cacheItem{
channel: channel,
value: value,
expires: time.Now().Add(ttl).UnixNano(),
}
shard.buffer[index].Store(item)
}

View File

@@ -0,0 +1,80 @@
package config
import (
"math"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRollingCache(t *testing.T) {
size := 30
shardCount := 1
cache := newRollingCache(size, shardCount)
// Testing Set.
channel := "channel1"
value := channelOptionsResult{nsName: "value1"}
ttl := 200 * time.Millisecond
cache.Set(channel, value, ttl)
// Testing Get after Set.
gotValue, ok := cache.Get(channel)
require.True(t, ok)
require.Equal(t, value, gotValue)
// Testing Get after expiry.
time.Sleep(400 * time.Millisecond)
_, ok = cache.Get(channel)
require.False(t, ok)
// Testing if data rolls over.
// Setting more data than cache size, to see if rolling over works without errors.
for i := 0; i < size+10; i++ {
channel := "channel" + strconv.Itoa(i)
value := channelOptionsResult{nsName: "value" + strconv.Itoa(i)}
cache.Set(channel, value, ttl)
}
// The first 10 items should have been rolled over and replaced.
// So, channel0, channel1, ... channel9 should no longer be in the cache.
for i := 0; i < 10; i++ {
channel := "channel" + strconv.Itoa(i)
_, ok = cache.Get(channel)
require.False(t, ok, channel)
}
// Whereas, the items from channel10 onwards should still be there.
for i := 10; i < size+10; i++ {
channel := "channel" + strconv.Itoa(i)
gotValue, ok := cache.Get(channel)
require.True(t, ok)
require.Equal(t, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, gotValue)
}
}
// TestRollingCacheIndexOverflow guards against the rolling index wrapping into
// negative values. The index is bumped on every Set, so on a long-running node
// it eventually overflows; with a signed counter the wrap made the modulo
// negative and Set panicked with "index out of range".
func TestRollingCacheIndexOverflow(t *testing.T) {
cache := newRollingCache(channelOptionsCacheSize, 1)
shard := cache.shardForKey("channel")
shard.index.Store(math.MaxUint32) // Next Set wraps the index.
ttl := time.Minute
for i := 0; i < 2*channelOptionsCacheSize; i++ {
channel := "channel" + strconv.Itoa(i)
cache.Set(channel, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, ttl)
}
// The most recent shardSize items must still be readable after the wrap.
for i := channelOptionsCacheSize; i < 2*channelOptionsCacheSize; i++ {
channel := "channel" + strconv.Itoa(i)
gotValue, ok := cache.Get(channel)
require.True(t, ok, channel)
require.Equal(t, channelOptionsResult{nsName: "value" + strconv.Itoa(i)}, gotValue)
}
}

429
internal/config/config.go Normal file
View File

@@ -0,0 +1,429 @@
// Package config contains Centrifugo Config and the code to load it.
package config
import (
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strings"
"github.com/centrifugal/centrifugo/v6/internal/config/envconfig"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/go-viper/mapstructure/v2"
"github.com/hashicorp/go-envparse"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
// Config contains configuration options of Centrifugo.
type Config struct {
// HTTP is a configuration for Centrifugo HTTP server.
HTTP configtypes.HTTPServer `mapstructure:"http_server" json:"http_server" envconfig:"http_server" toml:"http_server" yaml:"http_server" doc:"Configures the main HTTP server: listen address, ports, and the internal endpoints port."`
// Log is a configuration for logging.
Log configtypes.Log `mapstructure:"log" json:"log" envconfig:"log" toml:"log" yaml:"log" doc:"Configures logging: level and optional log file."`
// Engine is a configuration for Centrifugo engine. It's a handy combination of Broker and PresenceManager.
// Currently only memory and redis engines are supported – both implement all the features. For more granular
// control use `broker` and `presence_manager` options.
Engine configtypes.Engine `mapstructure:"engine" json:"engine" envconfig:"engine" toml:"engine" yaml:"engine" doc:"Selects the engine - a combined broker and presence manager. Supports <<memory>> (default) and <<redis>>. For finer control use <<broker>> and <<presence_manager>> instead."`
// Broker allows to configure a message broker to use. Broker is responsible for PUB/SUB functionality
// and channel message history and idempotency cache.
// By default, memory Broker is used. Memory broker is superfast, but it's not distributed and all
// data stored in memory (thus lost after node restart). Redis Broker provides seamless horizontal
// scalability, fault-tolerance, and persistence over Centrifugo restarts. Centrifugo also supports
// Nats Broker which only implements at most once PUB/SUB semantics.
Broker configtypes.Broker `mapstructure:"broker" json:"broker" envconfig:"broker" toml:"broker" yaml:"broker" doc:"Configures the message broker (PUB/SUB, history, idempotency cache) when set separately from the engine. Supports <<memory>> (default, not distributed, lost on restart), <<redis>> (scalable and persistent), and <<nats>> (at-most-once PUB/SUB only)."`
// PresenceManager allows to configure a presence manager to use. Presence manager is responsible for
// presence information storage and retrieval. By default, memory PresenceManager is used. Memory
// PresenceManager is superfast, but it's not distributed. Redis PresenceManager provides a seamless
// horizontal scalability.
PresenceManager configtypes.PresenceManager `mapstructure:"presence_manager" json:"presence_manager" envconfig:"presence_manager" toml:"presence_manager" yaml:"presence_manager" doc:"Configures the presence manager when set separately from the engine. Supports <<memory>> (default, not distributed) and <<redis>> (scalable)."`
// MapBroker allows to configure a map broker for synchronized keyed state channels.
// When enabled, clients can use map subscription types for channels in namespaces
// that have a map subscription_type.
MapBroker configtypes.MapBroker `mapstructure:"map_broker" json:"map_broker" envconfig:"map_broker" toml:"map_broker" yaml:"map_broker" doc:"Configures the map broker for synchronized keyed-state channels. Used by namespaces whose subscription type is <<map>>."`
// Controller is a configuration for custom Centrifugo Controller used for
// cross-node communication in multi-node clusters.
Controller configtypes.Controller `mapstructure:"controller" json:"controller" envconfig:"controller" toml:"controller" yaml:"controller" doc:"Configures the controller used for cross-node communication in multi-node clusters."`
// SharedPoll contains configuration for shared poll subscriptions.
SharedPoll configtypes.SharedPoll `mapstructure:"shared_poll" json:"shared_poll" envconfig:"shared_poll" toml:"shared_poll" yaml:"shared_poll" doc:"Configures shared poll subscriptions."`
// Client contains real-time client connection related configuration.
Client configtypes.Client `mapstructure:"client" json:"client" envconfig:"client" toml:"client" yaml:"client" doc:"Configures real-time client connections: auth, tokens, allowed origins, limits, proxies, and the insecure mode."`
// Channel contains real-time channel related configuration.
Channel configtypes.Channel `mapstructure:"channel" json:"channel" envconfig:"channel" toml:"channel" yaml:"channel" doc:"Configures channels and namespaces: separators, default options, and per-namespace behavior."`
// RPC is a configuration for client RPC calls.
RPC configtypes.RPC `mapstructure:"rpc" json:"rpc" envconfig:"rpc" toml:"rpc" yaml:"rpc" doc:"Configures client RPC calls and RPC namespaces."`
// Proxies is an array of proxies with custom names for the more granular control of channel-related events
// in different channel namespaces.
Proxies configtypes.NamedProxies `mapstructure:"proxies" default:"[]" json:"proxies" envconfig:"proxies" yaml:"proxies" toml:"proxies" doc:"Defines named proxies that namespaces can reference to forward channel-related events to your backend."`
// HttpAPI is a configuration for HTTP server API. It's enabled by default.
HttpAPI configtypes.HttpAPI `mapstructure:"http_api" json:"http_api" envconfig:"http_api" toml:"http_api" yaml:"http_api" doc:"Configures the server HTTP API (enabled by default): API key, insecure mode, and whether it's exposed on the external port."`
// GrpcAPI is a configuration for gRPC server API. It's disabled by default.
GrpcAPI configtypes.GrpcAPI `mapstructure:"grpc_api" json:"grpc_api" envconfig:"grpc_api" toml:"grpc_api" yaml:"grpc_api" doc:"Configures the server gRPC API (disabled by default): enable flag, port, TLS, and auth."`
// Consumers is a configuration for message queue consumers. For example, Centrifugo can consume
// messages from PostgreSQL transactional outbox table, or from Kafka topics.
Consumers configtypes.Consumers `mapstructure:"consumers" default:"[]" json:"consumers" envconfig:"consumers" toml:"consumers" yaml:"consumers" doc:"Defines message queue consumers that ingest commands from external sources such as a PostgreSQL outbox table or Kafka topics."`
// WebSocket configuration. This transport is enabled by default.
WebSocket configtypes.WebSocket `mapstructure:"websocket" json:"websocket" envconfig:"websocket" toml:"websocket" yaml:"websocket" doc:"Configures the bidirectional WebSocket transport (enabled by default): message size limits, compression, ping/pong, and write timeouts."`
// SSE is a configuration for Server-Sent Events based bidirectional emulation transport.
SSE configtypes.SSE `mapstructure:"sse" json:"sse" envconfig:"sse" toml:"sse" yaml:"sse" doc:"Configures the bidirectional Server-Sent Events (EventSource) transport, which relies on the emulation endpoint. Disabled by default."`
// HTTPStream is a configuration for HTTP streaming based bidirectional emulation transport.
HTTPStream configtypes.HTTPStream `mapstructure:"http_stream" json:"http_stream" envconfig:"http_stream" toml:"http_stream" yaml:"http_stream" doc:"Configures the bidirectional HTTP-streaming transport, which relies on the emulation endpoint. Disabled by default."`
// WebTransport is a configuration for WebTransport transport. EXPERIMENTAL.
WebTransport configtypes.WebTransport `mapstructure:"webtransport" json:"webtransport" envconfig:"webtransport" toml:"webtransport" yaml:"webtransport" doc:"Configures the bidirectional WebTransport (HTTP/3) transport. Experimental and disabled by default."`
// UniSSE is a configuration for unidirectional Server-Sent Events transport.
UniSSE configtypes.UniSSE `mapstructure:"uni_sse" json:"uni_sse" envconfig:"uni_sse" toml:"uni_sse" yaml:"uni_sse" doc:"Configures the unidirectional Server-Sent Events (EventSource) transport. Disabled by default."`
// UniHTTPStream is a configuration for unidirectional HTTP streaming transport.
UniHTTPStream configtypes.UniHTTPStream `mapstructure:"uni_http_stream" json:"uni_http_stream" envconfig:"uni_http_stream" toml:"uni_http_stream" yaml:"uni_http_stream" doc:"Configures the unidirectional HTTP-streaming transport. Disabled by default."`
// UniWS is a configuration for unidirectional WebSocket transport.
UniWS configtypes.UniWebSocket `mapstructure:"uni_websocket" json:"uni_websocket" envconfig:"uni_websocket" toml:"uni_websocket" yaml:"uni_websocket" doc:"Configures the unidirectional WebSocket transport. Disabled by default."`
// UniGRPC is a configuration for unidirectional gRPC transport.
UniGRPC configtypes.UniGRPC `mapstructure:"uni_grpc" json:"uni_grpc" envconfig:"uni_grpc" toml:"uni_grpc" yaml:"uni_grpc" doc:"Configures the unidirectional gRPC transport: enable flag, port, and TLS. Disabled by default."`
// Emulation endpoint is enabled automatically when at least one bidirectional emulation transport
// is configured (SSE or HTTP Stream).
Emulation configtypes.Emulation `mapstructure:"emulation" json:"emulation" envconfig:"emulation" toml:"emulation" yaml:"emulation" doc:"Configures the emulation endpoint used by bidirectional SSE and HTTP-streaming transports. Enabled automatically when one of those transports is enabled."`
// Admin web UI configuration.
Admin configtypes.Admin `mapstructure:"admin" json:"admin" envconfig:"admin" toml:"admin" yaml:"admin" doc:"Configures the admin web UI and its API endpoints."`
// Prometheus metrics configuration.
Prometheus configtypes.Prometheus `mapstructure:"prometheus" json:"prometheus" envconfig:"prometheus" toml:"prometheus" yaml:"prometheus" doc:"Configures the Prometheus metrics endpoint."`
// Health check endpoint configuration.
Health configtypes.Health `mapstructure:"health" json:"health" envconfig:"health" toml:"health" yaml:"health" doc:"Configures the health check endpoint."`
// Swagger documentation (for server HTTP API) configuration.
Swagger configtypes.Swagger `mapstructure:"swagger" json:"swagger" envconfig:"swagger" toml:"swagger" yaml:"swagger" doc:"Configures the Swagger UI endpoint describing the server HTTP API."`
// Debug helps to enable Go profiling endpoints.
Debug configtypes.Debug `mapstructure:"debug" json:"debug" envconfig:"debug" toml:"debug" yaml:"debug" doc:"Configures Go pprof profiling endpoints. For debugging only - do not expose publicly."`
// Init is a configuration for connection initialization endpoint.
Init configtypes.ConnInit `mapstructure:"init" json:"init" envconfig:"init" toml:"init" yaml:"init" doc:"Configures the connection initialization endpoint."`
// OpenTelemetry is a configuration for OpenTelemetry tracing.
OpenTelemetry configtypes.OpenTelemetry `mapstructure:"opentelemetry" json:"opentelemetry" envconfig:"opentelemetry" toml:"opentelemetry" yaml:"opentelemetry" doc:"Configures OpenTelemetry tracing export."`
// Graphite is a configuration for export metrics to Graphite.
Graphite configtypes.Graphite `mapstructure:"graphite" json:"graphite" envconfig:"graphite" toml:"graphite" yaml:"graphite" doc:"Configures exporting metrics to Graphite."`
// UsageStats is a configuration for usage stats sending.
UsageStats configtypes.UsageStats `mapstructure:"usage_stats" json:"usage_stats" envconfig:"usage_stats" toml:"usage_stats" yaml:"usage_stats" doc:"Configures anonymous usage statistics reporting. Set <<enabled>> to <<false>> to opt out."`
// Node is a configuration for Centrifugo Node as part of cluster.
Node configtypes.Node `mapstructure:"node" json:"node" envconfig:"node" toml:"node" yaml:"node" doc:"Configures this node as part of a Centrifugo cluster: node name and inter-node info settings."`
// Shutdown is a configuration for graceful shutdown.
Shutdown configtypes.Shutdown `mapstructure:"shutdown" json:"shutdown" envconfig:"shutdown" toml:"shutdown" yaml:"shutdown" doc:"Configures graceful shutdown behavior, such as the shutdown termination timeout."`
// PidFile is a path to write a file with Centrifugo process PID.
PidFile string `mapstructure:"pid_file" json:"pid_file" envconfig:"pid_file" toml:"pid_file" yaml:"pid_file" expose:"full" doc:"Path of a file to write the Centrifugo process PID to. Leave empty to disable."`
// EnableUnreleasedFeatures enables unreleased features. These features are not stable and may be removed even
// in minor release update. Evaluate and share feedback if you find some feature useful and want it to be stabilized.
EnableUnreleasedFeatures bool `mapstructure:"enable_unreleased_features" json:"enable_unreleased_features" envconfig:"enable_unreleased_features" toml:"enable_unreleased_features" yaml:"enable_unreleased_features" doc:"Enables unstable, unreleased features. These may change or be removed even in a minor release - do not rely on them in production."`
}
type Meta struct {
FileNotFound bool
UnknownKeys []string
UnknownEnvs []string
KnownEnvVars map[string]envconfig.VarInfo
DeprecationWarnings []string
}
func DefineFlags(rootCmd *cobra.Command) {
rootCmd.Flags().StringP("pid_file", "", "", "optional path to create PID file")
rootCmd.Flags().StringP("http_server.address", "a", "", "interface address to listen on")
rootCmd.Flags().StringP("http_server.port", "p", "8000", "port to bind HTTP server to")
rootCmd.Flags().StringP("http_server.internal_address", "", "", "custom interface address to listen on for internal endpoints")
rootCmd.Flags().StringP("http_server.internal_port", "", "", "custom port for internal endpoints")
rootCmd.Flags().StringP("engine.type", "", "memory", "engine to use: memory or redis")
rootCmd.Flags().BoolP("broker.enabled", "", false, "enable broker")
rootCmd.Flags().StringP("broker.type", "", "memory", "broker to use: memory, redis or nats")
rootCmd.Flags().BoolP("presence_manager.enabled", "", false, "enable presence manager")
rootCmd.Flags().StringP("presence_manager.type", "", "memory", "presence manager to use: memory or redis")
rootCmd.Flags().StringP("map_broker.type", "", "memory", "map broker to use: memory, redis or postgres")
rootCmd.Flags().StringP("log.level", "", "info", "set the log level: trace, debug, info, warn, error, fatal or none")
rootCmd.Flags().StringP("log.file", "", "", "optional log file - if not specified logs go to STDOUT")
rootCmd.Flags().BoolP("debug.enabled", "", false, "enable debug endpoints")
rootCmd.Flags().BoolP("admin.enabled", "", false, "enable admin web interface")
rootCmd.Flags().BoolP("admin.external", "", false, "expose admin web interface on external port")
rootCmd.Flags().BoolP("prometheus.enabled", "", false, "enable Prometheus metrics endpoint")
rootCmd.Flags().BoolP("swagger.enabled", "", false, "enable Swagger UI endpoint describing server HTTP API")
rootCmd.Flags().BoolP("health.enabled", "", false, "enable health check endpoint")
rootCmd.Flags().BoolP("uni_websocket.enabled", "", false, "enable unidirectional websocket endpoint")
rootCmd.Flags().BoolP("uni_sse.enabled", "", false, "enable unidirectional SSE (EventSource) endpoint")
rootCmd.Flags().BoolP("uni_http_stream.enabled", "", false, "enable unidirectional HTTP-streaming endpoint")
rootCmd.Flags().BoolP("sse.enabled", "", false, "enable bidirectional SSE (EventSource) endpoint (with emulation layer)")
rootCmd.Flags().BoolP("http_stream.enabled", "", false, "enable bidirectional HTTP-streaming endpoint (with emulation layer)")
rootCmd.Flags().BoolP("init.enabled", "", false, "enable connection init endpoint")
rootCmd.Flags().BoolP("client.insecure", "", false, "start in insecure client mode")
rootCmd.Flags().BoolP("http_api.insecure", "", false, "use insecure API mode")
rootCmd.Flags().BoolP("http_api.external", "", false, "expose API handler on external port")
rootCmd.Flags().BoolP("admin.insecure", "", false, "use insecure admin mode – no auth required for admin socket")
rootCmd.Flags().BoolP("grpc_api.enabled", "", false, "enable GRPC API server")
rootCmd.Flags().IntP("grpc_api.port", "", 10000, "port to bind GRPC API server to")
rootCmd.Flags().BoolP("uni_grpc.enabled", "", false, "enable unidirectional GRPC endpoint")
rootCmd.Flags().IntP("uni_grpc.port", "", 11000, "port to bind unidirectional GRPC server to")
}
func GetConfig(cmd *cobra.Command, configFile string) (Config, Meta, error) {
v := viper.NewWithOptions(viper.WithDecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
configtypes.StringToDurationHookFunc(),
configtypes.StringToPEMDataHookFunc(),
configtypes.StringToMapStringStringHookFunc(),
configtypes.StringToStringKeyValuesHookFunc(),
)))
if cmd != nil {
bindPFlags := []string{
"pid_file", "http_server.port", "http_server.address", "http_server.internal_port",
"http_server.internal_address", "log.level", "log.file", "engine.type", "broker.enabled", "broker.type",
"presence_manager.enabled", "presence_manager.type", "map_broker.type",
"debug.enabled", "admin.enabled", "admin.external",
"admin.insecure", "client.insecure", "http_api.insecure", "http_api.external", "prometheus.enabled",
"health.enabled", "grpc_api.enabled", "grpc_api.port", "uni_grpc.enabled", "uni_grpc.port",
"uni_websocket.enabled", "uni_sse.enabled", "uni_http_stream.enabled", "sse.enabled", "http_stream.enabled",
"swagger.enabled", "init.enabled",
}
for _, flag := range bindPFlags {
_ = v.BindPFlag(flag, cmd.Flags().Lookup(flag))
}
}
meta := Meta{}
if configFile != "" {
v.SetConfigFile(configFile)
err := v.ReadInConfig()
if err != nil {
var configFileNotFoundError *os.PathError
if errors.As(err, &configFileNotFoundError) {
meta.FileNotFound = true
} else {
return Config{}, Meta{}, fmt.Errorf("error reading config file %s: %w", configFile, err)
}
}
}
conf := &Config{}
err := v.Unmarshal(conf)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error unmarshalling config: %w", err)
}
var flagsSet []string
if cmd != nil {
cmd.Flags().Visit(func(f *pflag.Flag) {
if !slices.Contains(flagsSet, f.Name) {
flagsSet = append(flagsSet, f.Name) // pid_file, http_server.port, etc.
}
})
}
knownEnvVars := map[string]envconfig.VarInfo{}
varInfo, err := envconfig.ProcessWithoutNestedKeys("CENTRIFUGO", conf, flagsSet)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env: %w", err)
}
extendKnownEnvVars(knownEnvVars, varInfo)
for i, item := range conf.Channel.Namespaces {
varInfo, err = envconfig.Process("CENTRIFUGO_CHANNEL_NAMESPACES_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env namespaces: %w", err)
}
conf.Channel.Namespaces[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
for i, item := range conf.RPC.Namespaces {
varInfo, err = envconfig.Process("CENTRIFUGO_RPC_NAMESPACES_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env rpc namespaces: %w", err)
}
conf.RPC.Namespaces[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
for i, item := range conf.Proxies {
varInfo, err = envconfig.Process("CENTRIFUGO_PROXIES_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env named proxies: %w", err)
}
conf.Proxies[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
for i, item := range conf.Consumers {
varInfo, err = envconfig.Process("CENTRIFUGO_CONSUMERS_"+configtypes.NameForEnv(item.Name), &item)
if err != nil {
return Config{}, Meta{}, fmt.Errorf("error processing env consumers: %w", err)
}
conf.Consumers[i] = item
extendKnownEnvVars(knownEnvVars, varInfo)
}
meta.UnknownKeys = findUnknownKeys(v.AllSettings(), conf, "")
meta.UnknownEnvs = checkEnvironmentVars(knownEnvVars)
meta.KnownEnvVars = knownEnvVars
cfg := *conf
cfg, deprecationWarnings := applyConfigMigrations(cfg)
meta.DeprecationWarnings = deprecationWarnings
return cfg, meta, nil
}
func extendKnownEnvVars(knownEnvVars map[string]envconfig.VarInfo, varInfo []envconfig.VarInfo) {
for _, info := range varInfo {
knownEnvVars[info.Key] = info
}
}
// findValidKeys recursively finds valid keys in a struct, including embedded structs
func findValidKeys(typ reflect.Type, validKeys map[string]reflect.StructField) {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("mapstructure")
if tag != "" && tag != ",squash" {
// Normal field, add it to validKeys.
validKeys[tag] = field
} else if field.Anonymous && strings.Contains(tag, "squash") {
// Handle embedded fields with "squash".
embeddedType := field.Type
if embeddedType.Kind() == reflect.Pointer {
embeddedType = embeddedType.Elem()
}
if embeddedType.Kind() == reflect.Struct {
// Recursively process the embedded struct
findValidKeys(embeddedType, validKeys)
}
}
}
}
func findUnknownKeys(data map[string]interface{}, configStruct interface{}, parentKey string) []string {
var unknownKeys []string
val := reflect.ValueOf(configStruct)
if val.Kind() == reflect.Pointer && val.IsNil() {
// Create an instance if the struct pointer is nil to avoid panic.
val = reflect.New(val.Type().Elem())
}
if val.Kind() == reflect.Pointer {
val = val.Elem()
}
typ := val.Type()
// Build a set of valid keys from the struct's mapstructure tags, including embedded structs.
validKeys := make(map[string]reflect.StructField)
findValidKeys(typ, validKeys)
// Check each key in the map to see if it's in the valid keys set
for key, value := range data {
if field, exists := validKeys[key]; exists {
fieldValue := val.FieldByName(field.Name)
if (fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && fieldValue.Type().Elem().Kind() == reflect.Struct)) && !field.Anonymous {
if nestedMap, ok := value.(map[string]interface{}); ok {
// Handle pointers to structs specifically
if fieldValue.Kind() == reflect.Pointer && fieldValue.IsNil() {
fieldValue.Set(reflect.New(fieldValue.Type().Elem())) // Create new struct if nil
}
nestedStruct := fieldValue.Interface()
if fieldValue.Kind() == reflect.Pointer {
nestedStruct = fieldValue.Elem().Interface()
}
unknownKeys = append(unknownKeys, findUnknownKeys(nestedMap, nestedStruct, appendKeyPath(parentKey, key))...)
}
} else if fieldValue.Kind() == reflect.Slice {
// Handle each element in the slice if it is a map
if slice, ok := value.([]interface{}); ok {
for i, elem := range slice {
if elemMap, ok := elem.(map[string]interface{}); ok {
elementType := fieldValue.Type().Elem()
if elementType.Kind() == reflect.Pointer {
elementType = elementType.Elem()
}
if elementType.Kind() == reflect.Struct {
nestedStruct := reflect.New(elementType).Interface()
unknownKeys = append(unknownKeys, findUnknownKeys(elemMap, nestedStruct, appendKeyPath(appendKeyPath(parentKey, key), fmt.Sprintf("[%d]", i)))...)
}
}
}
}
}
} else {
unknownKeys = append(unknownKeys, appendKeyPath(parentKey, key))
}
}
return unknownKeys
}
func appendKeyPath(parent, key string) string {
if parent == "" {
return key
}
return parent + "." + key
}
func checkEnvironmentVars(knownEnvVars map[string]envconfig.VarInfo) []string {
var unknownEnvs []string
envPrefix := "CENTRIFUGO_"
envVars := os.Environ()
for _, envVar := range envVars {
kv, err := envparse.Parse(strings.NewReader(envVar))
if err != nil {
continue
}
for envKey := range kv {
if !strings.HasPrefix(envKey, envPrefix) {
continue
}
// Kubernetes automatically adds some variables which are not used by Centrifugo
// itself. We skip warnings about them.
if isKubernetesEnvVar(envKey) {
continue
}
// Centrifugo can extrapolate ${CENTRIFUGO_VAR_XXX} from env inside MapStringString type.
if isCustomEnvVar(envKey) {
continue
}
if _, ok := knownEnvVars[envKey]; !ok {
unknownEnvs = append(unknownEnvs, envKey)
}
}
}
return unknownEnvs
}
// k8sEnvRegex is used to filter out Kubernetes-injected environment variables.
// Centrifugo automatically scans environment variables starting with CENTRIFUGO_ and converts them to configuration options.
// However, Kubernetes automatically injects environment variables, so this regular expression filters out Kubernetes-injected environment variables.
// See isKubernetesEnvVar function for usage.
var k8sEnvRegex = regexp.MustCompile(`^CENTRIFUGO(?:_[A-Z0-9_]+)?_(PORT|SERVICE_)`)
func isKubernetesEnvVar(envKey string) bool {
return k8sEnvRegex.MatchString(envKey)
}
func isCustomEnvVar(envKey string) bool {
// Custom environment variables should start with "CENTRIFUGO_VAR_" prefix.
// This function checks if the environment variable is a custom one.
return strings.HasPrefix(envKey, "CENTRIFUGO_VAR_")
}
// DefaultConfig is a helper to be used in tests.
func DefaultConfig() Config {
conf, _, err := GetConfig(nil, "")
if err != nil {
panic("error during getting default config: " + err.Error())
}
return conf
}

View File

@@ -0,0 +1,164 @@
package config
import (
"os"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/stretchr/testify/require"
)
func getConfig(t *testing.T, configFile string) (Config, Meta) {
t.Helper()
conf, meta, err := GetConfig(nil, configFile)
require.NoError(t, err)
return conf, meta
}
func checkConfig(t *testing.T, conf Config) {
t.Helper()
require.NotNil(t, conf)
require.True(t, conf.HTTP.TLS.Enabled)
require.NotZero(t, conf.HTTP.TLS.ServerCAPem)
_, err := conf.HTTP.TLS.ToGoTLSConfig("test")
require.NoError(t, err)
require.Equal(t, "https://example.com/jwks", conf.Client.Token.JWKSPublicEndpoint)
require.Len(t, conf.Client.AllowedOrigins, 1)
require.Equal(t, "http://localhost:3000", conf.Client.AllowedOrigins[0])
require.Len(t, conf.Channel.Namespaces, 2)
require.Len(t, conf.Consumers, 1)
require.Equal(t, "kafka", conf.Consumers[0].Type)
require.Equal(t, "ppp", conf.Proxies[0].Name)
require.Equal(t, configtypes.Duration(time.Second), conf.Proxies[0].Timeout)
require.Equal(t, true, conf.Consumers[0].Kafka.TLS.Enabled)
require.Equal(t, configtypes.Duration(2*time.Second), conf.WebSocket.WriteTimeout)
require.Equal(t, "redis", conf.Engine.Type)
require.Equal(t, 30*time.Second, time.Duration(conf.Engine.Redis.PresenceTTL))
require.Equal(t, []string{"redis:6379"}, conf.Engine.Redis.Address)
require.Equal(t, configtypes.MapStringString(map[string]string{"x": "y"}), conf.Client.Proxy.Connect.HTTP.StaticHeaders)
require.Equal(t, configtypes.MapStringString(map[string]string{"X": "y"}), conf.Client.Proxy.Refresh.HTTP.StaticHeaders)
}
func TestConfigJSON(t *testing.T) {
conf, meta := getConfig(t, "testdata/config.json")
checkConfig(t, conf)
t.Log("unknown keys", meta.UnknownKeys)
t.Log("unknown keys", meta.UnknownEnvs)
require.Len(t, meta.UnknownKeys, 0)
require.Len(t, meta.UnknownEnvs, 0)
}
func TestConfigYAML(t *testing.T) {
conf, _ := getConfig(t, "testdata/config.yaml")
checkConfig(t, conf)
}
func TestConfigTOML(t *testing.T) {
conf, _ := getConfig(t, "testdata/config.toml")
checkConfig(t, conf)
}
func TestConfigEnvVars(t *testing.T) {
// Set environment variables for the test.
_ = os.Setenv("CENTRIFUGO_CLIENT_ALLOWED_ORIGINS", "* http://localhost:4000")
_ = os.Setenv("CENTRIFUGO_CLIENT_TOKEN_JWKS_PUBLIC_ENDPOINT", "https://example.com/jwks/new")
_ = os.Setenv("CENTRIFUGO_CONSUMERS_KAFKA_KAFKA_TLS_ENABLED", "false")
_ = os.Setenv("CENTRIFUGO_UNKNOWN_ENV", "1")
_ = os.Setenv("CENTRIFUGO_CHANNEL_NAMESPACES", `[{"name": "env"}]`)
_ = os.Setenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_HTTP_STATIC_HEADERS", `{"key": "value"}`)
_ = os.Setenv("CENTRIFUGO_WEBSOCKET_WRITE_TIMEOUT", `300ms`)
_ = os.Setenv("CENTRIFUGO_PROXIES", `[]`)
_ = os.Setenv("CENTRIFUGO_ENABLE_UNRELEASED_FEATURES", ``) // Empty options should be treated as unset.
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES", ``) // Empty options should be treated as unset.
_ = os.Setenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_TIMEOUT", ``) // Empty unset value should inherit default.
defer func() {
_ = os.Unsetenv("CENTRIFUGO_CONSUMERS_KAFKA_KAFKA_TLS_ENABLED")
_ = os.Unsetenv("CENTRIFUGO_UNKNOWN_ENV")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_ALLOWED_ORIGINS")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_TOKEN_JWKS_PUBLIC_ENDPOINT")
_ = os.Unsetenv("CENTRIFUGO_CHANNEL_NAMESPACES")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_HTTP_STATIC_HEADERS")
_ = os.Unsetenv("CENTRIFUGO_WEBSOCKET_WRITE_TIMEOUT")
_ = os.Unsetenv("CENTRIFUGO_PROXIES")
_ = os.Unsetenv("CENTRIFUGO_ENABLE_UNRELEASED_FEATURES")
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES")
_ = os.Unsetenv("CENTRIFUGO_CLIENT_PROXY_CONNECT_TIMEOUT")
}()
// Proceed with the test.
conf, meta := getConfig(t, "testdata/config.json")
require.Equal(t, "https://example.com/jwks/new", conf.Client.Token.JWKSPublicEndpoint)
require.Equal(t, false, conf.Consumers[0].Kafka.TLS.Enabled)
require.Equal(t, []string{"*", "http://localhost:4000"}, conf.Client.AllowedOrigins)
require.Len(t, conf.Channel.Namespaces, 1)
require.Equal(t, "env", conf.Channel.Namespaces[0].Name)
require.Len(t, meta.UnknownEnvs, 1)
require.Len(t, meta.UnknownKeys, 0)
require.Contains(t, meta.UnknownEnvs, "CENTRIFUGO_UNKNOWN_ENV")
require.Equal(t, configtypes.MapStringString(map[string]string{"key": "value"}), conf.Client.Proxy.Connect.HTTP.StaticHeaders)
require.Equal(t, configtypes.Duration(300*time.Millisecond), conf.WebSocket.WriteTimeout)
require.Equal(t, time.Second, time.Duration(conf.Client.Proxy.Connect.Timeout))
require.Len(t, conf.RPC.Namespaces, 0)
require.Len(t, conf.Proxies, 0)
}
func TestConfigEnvVarsNamedArray(t *testing.T) {
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES", `rpc1 rpc2`)
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_ENABLED", `true`)
_ = os.Setenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_NAME", `custom`)
defer func() {
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES")
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_ENABLED")
_ = os.Unsetenv("CENTRIFUGO_RPC_NAMESPACES_RPC1_PROXY_NAME")
}()
conf, meta := getConfig(t, "testdata/config.json")
require.Len(t, meta.UnknownEnvs, 0)
require.Len(t, conf.RPC.Namespaces, 2)
require.Equal(t, "rpc1", conf.RPC.Namespaces[0].Name)
require.Equal(t, "rpc2", conf.RPC.Namespaces[1].Name)
require.Equal(t, true, conf.RPC.Namespaces[0].ProxyEnabled)
require.Equal(t, "custom", conf.RPC.Namespaces[0].ProxyName)
}
// TestIsKubernetesEnvVar validates the isKubernetesEnvVar function.
func TestIsKubernetesEnvVar(t *testing.T) {
tests := []struct {
envKey string
expected bool
}{
{"CENTRIFUGO_PORT_11000_TCP", true},
{"CENTRIFUGO_SERVICE_", true},
{"CENTRIFUGO_PORT_11000", true},
{"CENTRIFUGO_SERVICE_22000", true},
{"CENTRIFUGO_APP_PORT_", true},
{"CENTRIFUGO_APP_SERVICE_", true},
{"CENTRIFUGO_APP_SERVICE_PORT_INTERNAL", true},
{"CENTRIFUGO_APP_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_9000_TCP_PROTO", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT_EXTERNAL", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT_11000_TCP_ADDR", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_HOST", true},
{"CENTRIFUGO_P0E8NNPNYU_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT", true},
{"CENTRIFUGO_P0E8NNPNYU_SERVICE_PORT_GRPC", true},
{"FOO_CENTRIFUGO_PORT_", false},
{"CENTRIFUGO__PORT_", false},
{"CENTRIFUGO_INVALID_", false},
{"CENTRIFUGO_APP", false},
{"CENTRIFUGO_APP_INVALID_", false},
{"centrifugo_port_", false},
{"CENTRIFUGO_LOG_FILE", false},
}
for _, tt := range tests {
t.Run(tt.envKey, func(t *testing.T) {
result := isKubernetesEnvVar(tt.envKey)
require.Equal(t, tt.expected, result, "Test failed for input: %s", tt.envKey)
})
}
}

6
internal/config/const.go Normal file
View File

@@ -0,0 +1,6 @@
package config
const (
DefaultProxyName = "default"
TransportErrorMode = "transport"
)

View File

@@ -0,0 +1,268 @@
package config
import (
"regexp"
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
)
// Container wraps configuration providing sync access, reload and compilation of required parts.
type Container struct {
configValue atomic.Value
channelOptionsCache *rollingCache
ChannelOptionsCacheTTL time.Duration
}
const (
channelOptionsCacheSize = 8
channelOptionsCacheShards = 128
)
// NewContainer creates new Container.
func NewContainer(config Config) (*Container, error) {
preparedConfig, err := getPreparedConfig(config)
if err != nil {
return nil, err
}
c := &Container{
channelOptionsCache: newRollingCache(channelOptionsCacheSize, channelOptionsCacheShards),
}
c.configValue.Store(preparedConfig)
return c, nil
}
func getPreparedConfig(config Config) (*Config, error) {
if err := config.Validate(); err != nil {
return &config, err
}
config, err := buildCompiledRegexes(config)
if err != nil {
return &config, err
}
return &config, nil
}
// Reload node config.
func (n *Container) Reload(c Config) error {
preparedConfig, err := getPreparedConfig(c)
if err != nil {
return err
}
n.configValue.Store(preparedConfig)
return nil
}
func buildCompiledRegexes(config Config) (Config, error) {
if config.Channel.WithoutNamespace.ChannelRegex != "" {
p, err := regexp.Compile(config.Channel.WithoutNamespace.ChannelRegex)
if err != nil {
return config, err
}
config.Channel.WithoutNamespace.Compiled.CompiledChannelRegex = p
}
var namespaces []configtypes.ChannelNamespace
for _, ns := range config.Channel.Namespaces {
if ns.ChannelRegex == "" {
namespaces = append(namespaces, ns)
continue
}
p, err := regexp.Compile(ns.ChannelRegex)
if err != nil {
return config, err
}
ns.Compiled.CompiledChannelRegex = p
namespaces = append(namespaces, ns)
}
config.Channel.Namespaces = namespaces
return config, nil
}
// namespaceName returns namespace name from channel if exists.
func (n *Container) namespaceName(config *Config, ch string) (string, string) {
cTrim := strings.TrimPrefix(ch, config.Channel.PrivatePrefix)
if config.Channel.NamespaceBoundary != "" && strings.Contains(cTrim, config.Channel.NamespaceBoundary) {
parts := strings.SplitN(cTrim, config.Channel.NamespaceBoundary, 2)
return parts[0], parts[1]
}
return "", ch
}
type channelOptionsResult struct {
nsName string
rest string
chOpts configtypes.ChannelOptions
ok bool
err error
}
// ChannelOptions returns channel options for channel using current channel config.
func (n *Container) ChannelOptions(ch string) (string, string, configtypes.ChannelOptions, bool, error) {
if n.ChannelOptionsCacheTTL > 0 {
if res, ok := n.channelOptionsCache.Get(ch); ok {
return res.nsName, res.rest, res.chOpts, res.ok, res.err
}
}
cfg := n.configValue.Load().(*Config)
nsName, rest := n.namespaceName(cfg, ch)
chOpts, ok, err := channelOpts(cfg, nsName)
// Apply global publication_data_format default if not set at namespace level
if chOpts.PublicationDataFormat == "" && cfg.Channel.PublicationDataFormat != "" {
chOpts.PublicationDataFormat = cfg.Channel.PublicationDataFormat
}
res := channelOptionsResult{
nsName: nsName,
rest: rest,
chOpts: chOpts,
ok: ok,
err: err,
}
if n.ChannelOptionsCacheTTL > 0 {
n.channelOptionsCache.Set(ch, res, n.ChannelOptionsCacheTTL)
}
return res.nsName, res.rest, res.chOpts, res.ok, res.err
}
// ValidChannelName checks whether the channel name is valid for the resolved
// channel options. When channel_regex is set it's matched against rest - the part
// of the channel which follows the namespace, as returned by ChannelOptions. When
// no channel_regex is set the channel must only contain ASCII characters.
func (n *Container) ValidChannelName(ch string, rest string, chOpts configtypes.ChannelOptions) (bool, error) {
if chOpts.ChannelRegex != "" {
if !chOpts.Compiled.CompiledChannelRegex.MatchString(rest) {
return false, nil
}
} else if !isASCII(ch) {
return false, nil
}
return true, nil
}
func isASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {
return false
}
}
return true
}
// NumNamespaces returns number of configured namespaces.
func (n *Container) NumNamespaces() int {
c := n.configValue.Load().(*Config)
return len(c.Channel.Namespaces)
}
// channelOpts searches for channel options for specified namespace key.
func channelOpts(c *Config, namespaceName string) (configtypes.ChannelOptions, bool, error) {
if namespaceName == "" {
return c.Channel.WithoutNamespace, true, nil
}
for _, n := range c.Channel.Namespaces {
if n.Name == namespaceName {
return n.ChannelOptions, true, nil
}
}
return configtypes.ChannelOptions{}, false, nil
}
// PersonalChannel returns personal channel for user based on node configuration.
func (n *Container) PersonalChannel(user string) string {
cfg := n.configValue.Load().(*Config)
if cfg.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace == "" {
return cfg.Channel.UserBoundary + user
}
return cfg.Client.SubscribeToUserPersonalChannel.PersonalChannelNamespace + cfg.Channel.NamespaceBoundary + cfg.Channel.UserBoundary + user
}
// Config returns a copy of node Config.
func (n *Container) Config() Config {
return *n.configValue.Load().(*Config)
}
// IsPrivateChannel checks if channel requires token to subscribe. In case of
// token-protected channel subscription request must contain a proper token.
func (n *Container) IsPrivateChannel(ch string) bool {
cfg := n.configValue.Load().(*Config)
if cfg.Channel.PrivatePrefix == "" {
return false
}
return strings.HasPrefix(ch, cfg.Channel.PrivatePrefix)
}
// IsUserLimited returns whether channel is user-limited.
func (n *Container) IsUserLimited(ch string) bool {
cfg := n.configValue.Load().(*Config)
userBoundary := cfg.Channel.UserBoundary
if userBoundary == "" {
return false
}
return strings.Contains(ch, userBoundary)
}
// UserAllowed checks if user can subscribe on channel - as channel
// can contain special part in the end to indicate which users allowed
// to subscribe on it.
func (n *Container) UserAllowed(ch string, user string) bool {
cfg := n.configValue.Load().(*Config)
userBoundary := cfg.Channel.UserBoundary
userSeparator := cfg.Channel.UserSeparator
if userBoundary == "" {
return true
}
if !strings.Contains(ch, userBoundary) {
return true
}
parts := strings.Split(ch, userBoundary)
if userSeparator == "" {
return parts[len(parts)-1] == user
}
allowedUsers := strings.Split(parts[len(parts)-1], userSeparator)
for _, allowedUser := range allowedUsers {
if user == allowedUser {
return true
}
}
return false
}
// rpcNamespaceName returns rpc namespace name from channel if exists.
func (n *Container) rpcNamespaceName(method string) string {
cfg := n.configValue.Load().(*Config)
if cfg.RPC.NamespaceBoundary != "" && strings.Contains(method, cfg.RPC.NamespaceBoundary) {
parts := strings.SplitN(method, cfg.RPC.NamespaceBoundary, 2)
return parts[0]
}
return ""
}
// RpcOptions returns rpc options for method using current config.
func (n *Container) RpcOptions(method string) (configtypes.RpcOptions, bool, error) {
cfg := n.configValue.Load().(*Config)
return rpcOpts(cfg, n.rpcNamespaceName(method))
}
// NumRpcNamespaces returns number of configured rpc namespaces.
func (n *Container) NumRpcNamespaces() int {
cfg := n.configValue.Load().(*Config)
return len(cfg.RPC.Namespaces)
}
// rpcOpts searches for channel options for specified namespace key.
func rpcOpts(c *Config, namespaceName string) (configtypes.RpcOptions, bool, error) {
if namespaceName == "" {
return c.RPC.WithoutNamespace, true, nil
}
for _, n := range c.RPC.Namespaces {
if n.Name == namespaceName {
return n.RpcOptions, true, nil
}
}
return configtypes.RpcOptions{}, false, nil
}

Some files were not shown because too many files have changed in this diff Show More