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