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

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

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

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

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

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

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

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