snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
misc/* linguist-vendored
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: centrifugal
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
17
.github/dependabot.yml
vendored
Normal 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
13
.github/renovate.json
vendored
Normal 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
65
.github/workflows/release.yml
vendored
Normal 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
49
.github/workflows/test.yml
vendored
Normal 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
30
.gitignore
vendored
Normal 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
1
.golangci-lint-version
Normal file
@@ -0,0 +1 @@
|
||||
v2.13.1
|
||||
35
.golangci.yml
Normal file
35
.golangci.yml
Normal 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
129
.goreleaser.yml
Normal 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
1568
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
119
CONTRIBUTING.md
Normal file
119
CONTRIBUTING.md
Normal 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
20
Dockerfile
Normal 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
202
LICENSE
Normal 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
86
Makefile
Normal 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
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Centrifugo
|
||||
|
||||
_Scalable real-time messaging server in a language-agnostic way._
|
||||
|
||||
[](https://github.com/centrifugal/centrifugo/actions/workflows/test.yml)
|
||||
[](https://github.com/centrifugal/centrifugo/releases)
|
||||
[](https://hub.docker.com/r/centrifugo/centrifugo)
|
||||
[](https://github.com/centrifugal/centrifugo/blob/master/LICENSE)
|
||||
[](https://t.me/joinchat/ABFVWBE0AhkyyhREoaboXQ)
|
||||
[](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
85
SECURITY.md
Normal 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
18
buf.yaml
Normal 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
127
docker-compose.yml
Normal 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
145
go.mod
Normal 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
437
go.sum
Normal 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
201
internal/admin/handlers.go
Normal 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
|
||||
}
|
||||
218
internal/admin/handlers_test.go
Normal file
218
internal/admin/handlers_test.go
Normal 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 := ¢rifuge.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 := ¢rifuge.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
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
747
internal/api/api_test.go
Normal 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
314
internal/api/consuming.go
Normal 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)
|
||||
}
|
||||
132
internal/api/consuming_gen.go
Normal file
132
internal/api/consuming_gen.go
Normal 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
|
||||
}
|
||||
74
internal/api/consuming_test.go
Normal file
74
internal/api/consuming_test.go
Normal 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
77
internal/api/grpc.go
Normal 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
|
||||
}
|
||||
400
internal/api/grpc_handler_gen.go
Normal file
400
internal/api/grpc_handler_gen.go
Normal 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
35
internal/api/grpc_test.go
Normal 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
95
internal/api/handler.go
Normal 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
881
internal/api/handler_gen.go
Normal 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)
|
||||
}
|
||||
774
internal/api/handler_legacy.go
Normal file
774
internal/api/handler_legacy.go
Normal 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
|
||||
}
|
||||
106
internal/api/handler_test.go
Normal file
106
internal/api/handler_test.go
Normal 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
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
1070
internal/apiproto/api.proto
Normal file
File diff suppressed because it is too large
Load Diff
1603
internal/apiproto/api_grpc.pb.go
Normal file
1603
internal/apiproto/api_grpc.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
26
internal/apiproto/decode.go
Normal file
26
internal/apiproto/decode.go
Normal 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)
|
||||
}
|
||||
225
internal/apiproto/decode_request_gen.go
Normal file
225
internal/apiproto/decode_request_gen.go
Normal 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
|
||||
}
|
||||
25
internal/apiproto/encode.go
Normal file
25
internal/apiproto/encode.go
Normal 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)
|
||||
}
|
||||
7
internal/apiproto/encode_error.go
Normal file
7
internal/apiproto/encode_error.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package apiproto
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func EncodeError(err *Error) ([]byte, error) {
|
||||
return json.Marshal(err)
|
||||
}
|
||||
117
internal/apiproto/encode_response_gen.go
Normal file
117
internal/apiproto/encode_response_gen.go
Normal 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)
|
||||
}
|
||||
115
internal/apiproto/encode_result_gen.go
Normal file
115
internal/apiproto/encode_result_gen.go
Normal 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)
|
||||
}
|
||||
93
internal/apiproto/errors.go
Normal file
93
internal/apiproto/errors.go
Normal 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
36
internal/apiproto/generate.sh
Executable 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
|
||||
27
internal/apiproto/generate_tags.sh
Normal file
27
internal/apiproto/generate_tags.sh
Normal 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
28
internal/apiproto/raw.go
Normal 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
|
||||
}
|
||||
35
internal/apiproto/raw_test.go
Normal file
35
internal/apiproto/raw_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1
internal/apiproto/readme.md
Normal file
1
internal/apiproto/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
Centrifugo server API Protobuf definitions.
|
||||
4195
internal/apiproto/swagger/api.swagger.json
Normal file
4195
internal/apiproto/swagger/api.swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1523
internal/apiproto/swagger/api.swagger.proto
Normal file
1523
internal/apiproto/swagger/api.swagger.proto
Normal file
File diff suppressed because it is too large
Load Diff
82
internal/apiproto/swagger/generate_swagger.sh
Normal file
82
internal/apiproto/swagger/generate_swagger.sh
Normal 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
|
||||
31
internal/apiproto/swagger/google/api/annotations.proto
Normal file
31
internal/apiproto/swagger/google/api/annotations.proto
Normal 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;
|
||||
}
|
||||
375
internal/apiproto/swagger/google/api/http.proto
Normal file
375
internal/apiproto/swagger/google/api/http.proto
Normal 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¶m=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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
21
internal/app/cmd.go
Normal 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
326
internal/app/engine.go
Normal 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 ¢rifuge.MemoryBrokerConfig{}, nil
|
||||
}
|
||||
|
||||
func memoryPresenceManagerConfig() (*centrifuge.MemoryPresenceManagerConfig, error) {
|
||||
return ¢rifuge.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
23
internal/app/graphite.go
Normal 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
120
internal/app/grpc.go
Normal 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
47
internal/app/log.go
Normal 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
635
internal/app/mux.go
Normal 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
122
internal/app/node.go
Normal 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
49
internal/app/origin.go
Normal 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
533
internal/app/proxy.go
Normal 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
445
internal/app/run.go
Normal 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
81
internal/app/tls.go
Normal 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
10
internal/build/build.go
Normal 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
|
||||
48
internal/cli/checkconfig.go
Normal file
48
internal/cli/checkconfig.go
Normal 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)
|
||||
}
|
||||
}
|
||||
95
internal/cli/checksubtoken.go
Normal file
95
internal/cli/checksubtoken.go
Normal 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)
|
||||
}
|
||||
88
internal/cli/checktoken.go
Normal file
88
internal/cli/checktoken.go
Normal 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
352
internal/cli/configdoc.go
Normal 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(§ion, "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
|
||||
}
|
||||
1502
internal/cli/configdoc/template.html
Normal file
1502
internal/cli/configdoc/template.html
Normal file
File diff suppressed because it is too large
Load Diff
103
internal/cli/defaultconfig.go
Normal file
103
internal/cli/defaultconfig.go
Normal 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
140
internal/cli/defaultenv.go
Normal 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
45
internal/cli/genconfig.go
Normal 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
123
internal/cli/gensubtoken.go
Normal 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
106
internal/cli/gentoken.go
Normal 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
38
internal/cli/serve.go
Normal 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
25
internal/cli/version.go
Normal 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
19
internal/client/client.go
Normal 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
1332
internal/client/handler.go
Normal file
File diff suppressed because it is too large
Load Diff
1876
internal/client/handler_test.go
Normal file
1876
internal/client/handler_test.go
Normal file
File diff suppressed because it is too large
Load Diff
117
internal/client/track_signature.go
Normal file
117
internal/client/track_signature.go
Normal 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
|
||||
}
|
||||
332
internal/client/track_signature_bench_test.go
Normal file
332
internal/client/track_signature_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
229
internal/client/track_signature_test.go
Normal file
229
internal/client/track_signature_test.go
Normal 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"))
|
||||
}
|
||||
1
internal/clientcontext/context.go
Normal file
1
internal/clientcontext/context.go
Normal file
@@ -0,0 +1 @@
|
||||
package clientcontext
|
||||
24
internal/clientcontext/emulated_headers.go
Normal file
24
internal/clientcontext/emulated_headers.go
Normal 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)
|
||||
}
|
||||
5
internal/clientstorage/storage.go
Normal file
5
internal/clientstorage/storage.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package clientstorage
|
||||
|
||||
const (
|
||||
KeyMeta = "meta"
|
||||
)
|
||||
73
internal/config/cache.go
Normal file
73
internal/config/cache.go
Normal 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)
|
||||
}
|
||||
80
internal/config/cache_test.go
Normal file
80
internal/config/cache_test.go
Normal 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
429
internal/config/config.go
Normal 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
|
||||
}
|
||||
164
internal/config/config_test.go
Normal file
164
internal/config/config_test.go
Normal 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
6
internal/config/const.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
DefaultProxyName = "default"
|
||||
TransportErrorMode = "transport"
|
||||
)
|
||||
268
internal/config/container.go
Normal file
268
internal/config/container.go
Normal 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
Reference in New Issue
Block a user