Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/nats.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: nats


on:
push:
paths:
- 'nats/**'
- 'util/**'
- 'go.sum'
- 'go.mod'
- '.github/workflows/nats.yml'

jobs:
golang_unit_tests:
name: Golang unit tests for nats
runs-on: ubuntu-latest
steps:
# See: https://github.com/marketplace/actions/checkout
# Checkout git repository and submodules
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

# See: https://github.com/marketplace/actions/setup-go-environment
- name: Setup Golang
uses: actions/setup-go@v5
with:
go-version: '1.25'
check-latest: true

# Run 'go test' in specified test target
- name: Run Golang tests
run: |
cd nats
go test
7 changes: 7 additions & 0 deletions ccStartup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,10 @@ The configuration file for the startup contains the targets where the topology s
- `nats.url`: NATS server URL
- `nats.subject`: NATS subject where to publish the topology as JSON
- `nats.nkey_file`: Path to NKey file for authentification

The auth token need not be stored in the configuration file. It is also read
from `$CC_STARTUP_AUTH_TOKEN`, or from the file named by
`$CC_STARTUP_AUTH_TOKEN_FILE`, either of which takes precedence over
`http.auth_token`. A named file that cannot be read is an error, so a request
is never sent unauthenticated in its place. See the
[`util`](../util/README.md) package for the full precedence rules.
25 changes: 23 additions & 2 deletions ccStartup/ccStartup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ import (

cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
"github.com/ClusterCockpit/cc-lib/v2/ccTopology"
"github.com/ClusterCockpit/cc-lib/v2/util"
"github.com/nats-io/nats.go"
)

// EnvAuthToken overrides the configured HTTP endpoint auth token. It also
// accepts a "_FILE" variant naming a file that holds the token, so the token
// can come from a Docker or Kubernetes secret mount or from systemd
// LoadCredential; see util.SecretFromEnv for the precedence rules.
//
// The name is prefixed CC_ because cc-lib is linked into several applications,
// whose environments it must not silently claim names in.
const EnvAuthToken = "CC_STARTUP_AUTH_TOKEN"

// func StartupTopology(out chan lp.CCMessage) error {
// topo, err := ccTopology.LocalTopology()
// if err != nil {
Expand Down Expand Up @@ -76,14 +86,25 @@ func CCStartup(config json.RawMessage) error {

if len(out) > 0 {
if len(conf.HttpEndpoint.URL) > 0 {
// The token may come from the environment instead of the config
// file. An unreadable secret file is fatal rather than a silent
// fallback, so an unauthenticated request is never sent in its
// place.
authToken, err := util.SecretFromEnv(EnvAuthToken, conf.HttpEndpoint.AuthToken)
if err != nil {
err = fmt.Errorf("resolving %s: %w", EnvAuthToken, err)
cclog.ComponentError("CCStartup", err.Error())
return err
}

bodyReader := bytes.NewReader(out)
req, err := http.NewRequest(http.MethodPost, conf.HttpEndpoint.URL, bodyReader)
if err != nil {
err = fmt.Errorf("failed to create HTTP request: %w", err)
cclog.ComponentError("CCStartup", err.Error())
} else {
if len(conf.HttpEndpoint.AuthToken) > 0 {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", conf.HttpEndpoint.AuthToken))
if len(authToken) > 0 {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
Expand Down
35 changes: 33 additions & 2 deletions nats/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
// }
// }
//
// The username and password may instead come from the environment, via
// $CC_NATS_USERNAME and $CC_NATS_PASSWORD or the files named by their _FILE
// variants, which take precedence over the configuration file.
//
// Or using a credentials file:
//
// {
Expand Down Expand Up @@ -55,6 +59,7 @@ import (
"sync"

cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
"github.com/ClusterCockpit/cc-lib/v2/util"
"github.com/nats-io/nats.go"
)

Expand Down Expand Up @@ -99,6 +104,27 @@ func GetClient() *Client {
return clientInstance
}

// resolveCredentials returns the username and password to authenticate with.
// They may come from the environment ($CC_NATS_USERNAME, $CC_NATS_PASSWORD, or
// the files named by their _FILE variants) so that they need not be stored in
// the configuration file.
//
// Resolution happens here rather than in Init for three reasons: an explicitly
// passed config then behaves the same as the global one, Init is optional, and
// the plaintext never lands in the exported Keys, which an application may
// re-marshal or dump.
func resolveCredentials(cfg *NatsConfig) (username, password string, err error) {
if username, err = util.SecretFromEnv(EnvUsername, cfg.Username); err != nil {
return "", "", fmt.Errorf("resolving %s: %w", EnvUsername, err)
}

if password, err = util.SecretFromEnv(EnvPassword, cfg.Password); err != nil {
return "", "", fmt.Errorf("resolving %s: %w", EnvPassword, err)
}

return username, password, nil
}

// NewClient creates a new NATS client. If cfg is nil, uses the global Keys config.
func NewClient(cfg *NatsConfig) (*Client, error) {
if cfg == nil {
Expand All @@ -111,8 +137,13 @@ func NewClient(cfg *NatsConfig) (*Client, error) {

var opts []nats.Option

if cfg.Username != "" && cfg.Password != "" {
opts = append(opts, nats.UserInfo(cfg.Username, cfg.Password))
username, password, err := resolveCredentials(cfg)
if err != nil {
return nil, err
}

if username != "" && password != "" {
opts = append(opts, nats.UserInfo(username, password))
}

if cfg.CredsFilePath != "" {
Expand Down
119 changes: 119 additions & 0 deletions nats/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-lib.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package nats

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/ClusterCockpit/cc-lib/v2/util"
)

func TestResolveCredentials_Precedence(t *testing.T) {
tests := []struct {
name string
cfg NatsConfig
envUser string
envPassFile string
wantUser string
wantPassword string
}{
{
name: "config only",
cfg: NatsConfig{Username: "cfg-user", Password: "cfg-pass"},
wantUser: "cfg-user",
wantPassword: "cfg-pass",
},
{
name: "environment overrides config",
cfg: NatsConfig{Username: "cfg-user", Password: "cfg-pass"},
envUser: "env-user",
wantUser: "env-user",
wantPassword: "cfg-pass",
},
{
name: "secret file overrides config",
cfg: NatsConfig{Username: "cfg-user", Password: "cfg-pass"},
envPassFile: "file-pass\n",
wantUser: "cfg-user",
wantPassword: "file-pass",
},
{
name: "nothing configured",
wantUser: "",
wantPassword: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envUser != "" {
t.Setenv(EnvUsername, tt.envUser)
}
if tt.envPassFile != "" {
path := filepath.Join(t.TempDir(), "password")
if err := os.WriteFile(path, []byte(tt.envPassFile), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
t.Setenv(EnvPassword+util.EnvFileSuffix, path)
}

cfg := tt.cfg
user, password, err := resolveCredentials(&cfg)
if err != nil {
t.Fatalf("resolveCredentials failed: %v", err)
}
if user != tt.wantUser {
t.Errorf("expected username %q, got %q", tt.wantUser, user)
}
if password != tt.wantPassword {
t.Errorf("expected password %q, got %q", tt.wantPassword, password)
}

// The resolved secret must never be written back into the config.
if cfg.Username != tt.cfg.Username || cfg.Password != tt.cfg.Password {
t.Error("expected the config to be left unmodified")
}
})
}
}

func TestResolveCredentials_UnreadableSecretFile(t *testing.T) {
t.Setenv(EnvPassword+util.EnvFileSuffix, filepath.Join(t.TempDir(), "absent"))

cfg := NatsConfig{Username: "cfg-user", Password: "cfg-pass"}
_, _, err := resolveCredentials(&cfg)
if err == nil {
t.Fatal("expected an error for an unreadable secret file, got nil")
}
// The variable must be named so an operator can find the misconfiguration,
// and the config value must not be used as a silent fallback.
if !strings.Contains(err.Error(), EnvPassword) {
t.Errorf("expected the error to name %s, got %q", EnvPassword, err.Error())
}
}

func TestNewClient_RejectsUnreadableSecretFileBeforeConnecting(t *testing.T) {
t.Setenv(EnvPassword+util.EnvFileSuffix, filepath.Join(t.TempDir(), "absent"))

// An unroutable address: if credential resolution did not fail first, this
// would block on a connection attempt instead of returning promptly.
_, err := NewClient(&NatsConfig{Address: "nats://127.0.0.1:1", Password: "cfg-pass"})
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), EnvPassword) {
t.Errorf("expected the error to name %s, got %q", EnvPassword, err.Error())
}
}

func TestNewClient_RequiresAddress(t *testing.T) {
if _, err := NewClient(&NatsConfig{}); err == nil {
t.Error("expected an error for an empty address, got nil")
}
}
16 changes: 14 additions & 2 deletions nats/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ type NatsConfig struct {
// Keys holds the global NATS configuration loaded via Init.
var Keys NatsConfig

// Environment variables that override the corresponding configuration values.
// Each also accepts a "_FILE" variant naming a file that holds the value, so a
// credential can be supplied from a Docker or Kubernetes secret mount or from
// systemd LoadCredential; see util.SecretFromEnv for the precedence rules.
//
// The names are prefixed CC_ because cc-lib is linked into several
// applications, whose environments it must not silently claim names in.
const (
EnvUsername = "CC_NATS_USERNAME"
EnvPassword = "CC_NATS_PASSWORD"
)

const ConfigSchema = `{
"type": "object",
"description": "Configuration for NATS messaging client.",
Expand All @@ -32,11 +44,11 @@ const ConfigSchema = `{
"type": "string"
},
"username": {
"description": "Username for NATS authentication (optional).",
"description": "Username for NATS authentication (optional). Overridden by the CC_NATS_USERNAME environment variable when set, or by the contents of the file named by CC_NATS_USERNAME_FILE.",
"type": "string"
},
"password": {
"description": "Password for NATS authentication (optional).",
"description": "Password for NATS authentication (optional). Overridden by the CC_NATS_PASSWORD environment variable when set, or by the contents of the file named by CC_NATS_PASSWORD_FILE.",
"type": "string"
},
"creds-file-path": {
Expand Down
18 changes: 18 additions & 0 deletions receivers/eecptReceiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ type EECPTReceiverConfig struct {
Password string `json:"password"`
useBasicAuth bool

// Alternative sources for the credentials above, so that they need not be
// stored in the configuration file. See resolveSecrets.
UsernameEnv string `json:"username_env,omitempty"`
UsernameFile string `json:"username_file,omitempty"`
PasswordEnv string `json:"password_env,omitempty"`
PasswordFile string `json:"password_file,omitempty"`

AnalysisBufferLength int `json:"analysis_buffer_size"`
AnalysisInterval string `json:"analysis_interval"`
AnalysisMetric string `json:"analysis_metric"`
Expand Down Expand Up @@ -420,6 +427,17 @@ func NewEECPTReceiver(name string, config json.RawMessage) (Receiver, error) {
}
}

// Resolve credentials before the basic authentication check below, so that
// values supplied from the environment or a secret file count as
// configured.
if err := resolveSecrets(
secretRef{"username", &r.config.Username, r.config.UsernameEnv, r.config.UsernameFile},
secretRef{"password", &r.config.Password, r.config.PasswordEnv, r.config.PasswordFile},
); err != nil {
cclog.ComponentError(r.name, err.Error())
return nil, err
}

if len(r.config.Username) > 0 || len(r.config.Password) > 0 {
r.config.useBasicAuth = true
}
Expand Down
15 changes: 15 additions & 0 deletions receivers/eecptReceiver.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,18 @@ When a phase transition is detected, the receiver generates an event:
- **Tags**: `type=node`, `stype=application`, `stype-id=<jobid>`
- **Fields**: `value="region changed"`
- **Timestamp**: Current time

### Credentials from the environment

The credentials above need not be stored in the configuration file. Each of
`username`, `password` has two sibling keys that select an alternative source:

- `username_env`: name of an environment variable holding the value
- `username_file`: path to a file holding the value
- `password_env`: name of an environment variable holding the value
- `password_file`: path to a file holding the value

The environment variable takes precedence over the file, and the file over the
inline value. A named file that cannot be read is an error rather than a silent
fallback, so a stale credential is never used in its place. See the
[`util`](../util/README.md) package for the full rules.
18 changes: 18 additions & 0 deletions receivers/httpReceiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ type HttpReceiverConfig struct {
Username string `json:"username"` // Basic auth username (optional)
Password string `json:"password"` // Basic auth password (optional)
useBasicAuth bool

// Alternative sources for the credentials above, so that they need not be
// stored in the configuration file. See resolveSecrets.
UsernameEnv string `json:"username_env,omitempty"`
UsernameFile string `json:"username_file,omitempty"`
PasswordEnv string `json:"password_env,omitempty"`
PasswordFile string `json:"password_file,omitempty"`
}

type HttpReceiver struct {
Expand Down Expand Up @@ -139,6 +146,17 @@ func NewHttpReceiver(name string, config json.RawMessage) (Receiver, error) {
}
}

// Resolve credentials before the basic authentication check below, so that
// values supplied from the environment or a secret file count as
// configured.
if err := resolveSecrets(
secretRef{"username", &r.config.Username, r.config.UsernameEnv, r.config.UsernameFile},
secretRef{"password", &r.config.Password, r.config.PasswordEnv, r.config.PasswordFile},
); err != nil {
cclog.ComponentError(r.name, err.Error())
return nil, err
}

if len(r.config.Username) > 0 || len(r.config.Password) > 0 {
r.config.useBasicAuth = true
}
Expand Down
Loading
Loading