Skip to content
Open
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
4 changes: 3 additions & 1 deletion cmd/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ Secret keys are any key whose name contains: SECRET, PASSWORD, KEY, TOKEN.`,
var configGetCmd = &cobra.Command{
Use: "get <key>",
Short: "Get a single configuration value",
Long: `Return the raw value of KEY from .env (or .env.{env} with --env).
Long: `Return the value of KEY from the same .env cascade used by build.
Use --env to select the cascade environment; otherwise ENV selects it.
Later layers override earlier ones, with .env.local winning by default.
Output is the plain value only, making it safe for scripting.

Exits non-zero if the key is not found.`,
Expand Down
43 changes: 43 additions & 0 deletions cmd/commands/config_get_cascade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package commands

import (
"bytes"
"os"
"path/filepath"
"testing"
)

// Purpose: config get must resolve the same cascade as a local build (D-0919).
// Inputs: fake dotenv layers and environment selectors. Outputs: winning value.
// Constraints: no config.Load, process exports, network, or real secrets.
func TestConfigGetCascade(t *testing.T) {
for _, tc := range []struct {
name, base, processEnv, flagEnv, legacy, want string
layers map[string]string
}{
{"local override", "HASURA_JWT_KEY=fake-base\n", "", "", "", "fake-local", map[string]string{".env.local": "HASURA_JWT_KEY=fake-local\n"}},
{"base environment selector", "ENV=prod\nHASURA_JWT_KEY=fake-base\n", "", "", "", "fake-prod", map[string]string{".env.prod": "HASURA_JWT_KEY=fake-prod\n"}},
{"process environment selector", "ENV=prod\nHASURA_JWT_KEY=fake-base\n", "staging", "", "", "fake-staging", map[string]string{".env.staging": "HASURA_JWT_KEY=fake-staging\n"}},
{"explicit environment selector", "HASURA_JWT_KEY=fake-base\n", "prod", "qa", "", "fake-qa", map[string]string{".env.qa": "HASURA_JWT_KEY=fake-qa\n"}},
{"secrets override environment", "HASURA_JWT_KEY=fake-base\n", "", "", "", "fake-secret", map[string]string{".env.dev": "HASURA_JWT_KEY=fake-dev\n", ".env.secrets": "HASURA_JWT_KEY=fake-secret\n"}},
{"legacy base wins", "HASURA_JWT_KEY=fake-base\n", "", "", "1", "fake-base", map[string]string{".env.local": "HASURA_JWT_KEY=fake-local\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ENV", tc.processEnv)
t.Setenv("NSELF_LEGACY_ENV_ORDER", tc.legacy)
dir := makeNSelfProject(t, tc.base)
for name, body := range tc.layers {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
root := newConfigCmd()
root.SetErr(new(bytes.Buffer))
root.SetArgs([]string{"config", "get", "HASURA_JWT_KEY", "--reveal", "--env", tc.flagEnv})
out, err := captureStdout(t, root.Execute)
if err != nil || out != tc.want+"\n" {
t.Fatalf("output=%q err=%v, want %q", out, err, tc.want+"\n")
}
})
}
}
17 changes: 7 additions & 10 deletions cmd/commands/config_show_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package commands
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

Expand Down Expand Up @@ -93,16 +92,14 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
return err
}

envFile := envFileName(projectDir, envFlag)
pairs, err := config.ReadEnvFile(envFile)
if envFlag == "" {
envFlag, _ = config.ResolveEnv(projectDir)
}
explanation, err := config.ExplainKey(projectDir, envFlag, key)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("env file not found: %s", envFile)
}
return config.EnvReadError(envFile, err)
return err
}

val, ok := pairs[key]
envFile, val, ok := explanation.Winner()
if !ok {
return fmt.Errorf("key not found: %s", key)
}
Expand All @@ -111,7 +108,7 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
// compat.V15(P7-REG-09): --json ignored -> envelope
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut && compat.V15() {
return output.EmitData(pilotWriter(), "config get", configGetData{
Key: key, Value: shown, Masked: shown != val, File: filepath.Base(envFile),
Key: key, Value: shown, Masked: shown != val, File: envFile,
})
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/commands/license_grace.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,12 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o
masked := license.MaskKey(key)
if err != nil {
doc.Keys = append(doc.Keys, licenseRefreshKey{Key: masked, Error: "refresh failed: " + licenseScrub(err.Error())})
fmt.Fprintf(os.Stderr, "%s: refresh failed: %v\n", masked, err)
fmt.Fprintf(os.Stderr, "%s: refresh failed: %v\n", masked, licenseScrub(err.Error(), key))
continue
}
if !result.Valid {
doc.Keys = append(doc.Keys, licenseRefreshKey{Key: masked, Error: "invalid: " + licenseScrub(result.Message)})
fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, result.Message)
fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, licenseScrub(result.Message, key))
continue
}
row := licenseRefreshKey{Key: masked, Refreshed: true, Tier: licenseScrub(result.Tier)}
Expand All @@ -161,7 +161,7 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o
}
doc.Keys = append(doc.Keys, row)
fmt.Printf("%s: refreshed (tier: %s, expires: %s)\n",
masked, result.Tier, result.ExpiresAt.Format("2006-01-02"))
masked, row.Tier, result.ExpiresAt.Format("2006-01-02"))
refreshed = true
}
setData(cmd, doc)
Expand Down
90 changes: 90 additions & 0 deletions cmd/commands/license_human_scrub_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package commands

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/spf13/cobra"
)

func TestLicenseHumanDiagnosticsScrubbed(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
body map[string]any
link bool
}{
{"validate reason", licenseValidateCmd, map[string]any{"valid": false}, false},
{"refresh reason", licenseRefreshCmd, map[string]any{"valid": false}, false},
{"revalidate reason", licenseRevalidateCmd, map[string]any{"valid": false}, false},
{"migration error", licenseMigrateCmd, map[string]any{"error": "hostile"}, false},
{"migration status", licenseMigrateCmd, map[string]any{"migration_status": "hostile"}, false},
{"migration link error", licenseMigrateCmd, map[string]any{"error": "hostile"}, true},
{"migration linked", licenseMigrateCmd, map[string]any{"already_migrated": true, "account_id": "hostile", "tier": "hostile"}, true},
{"migration completed", licenseMigrateCmd, map[string]any{"migrated": true, "account_id": "hostile", "tier": "hostile"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
fx := newLicenseFixture(t)
t.Setenv("NSELF_LICENSE_KEY_1", fixtureLicenseKey)
hostile := fixtureLicenseKey + " " + fx.jwt + " " + fixtureAccessToken
for k, value := range tc.body {
if value == "hostile" {
tc.body[k] = hostile
}
}
tc.body["reason"] = hostile
fx.reply(t, tc.body)
flags := map[string]string{}
if tc.link {
flags["account-id"] = "00000000-0000-0000-0000-000000000001"
}
var diagnostics bytes.Buffer
tc.cmd.SetErr(&diagnostics)
t.Cleanup(func() { tc.cmd.SetErr(nil) })
var out string
var runErr error
stderr := captureStderr(t, func() {
out, runErr = runLicenseJSON(t, tc.cmd, flags)
})
all := out + stderr + diagnostics.String()
if runErr != nil {
all += runErr.Error()
}
for _, secret := range []string{fixtureLicenseKey, fx.jwt, fixtureAccessToken} {
if strings.Contains(all, secret) {
t.Error("human diagnostic or JSON contains an unredacted synthetic secret")
}
}
if !strings.Contains(all, "[REDACTED]") {
t.Error("expected hostile reply to reach the scrubbed diagnostic or document")
}
})
}
}

func TestMigrationStatusUsesHeader(t *testing.T) {
for _, key := range []string{fixtureLicenseKey, "synthetic-key-with+reserved&characters"} {
t.Run(key, func(t *testing.T) {
requests := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests <- r
_, _ = w.Write([]byte(`{"migration_status":"legacy"}`))
}))
defer srv.Close()
if _, err := fetchMigrationStatus(context.Background(), key, srv.URL); err != nil {
t.Fatal(err)
}
r := <-requests
if r.Method != http.MethodGet || r.URL.Path != "/license/migration-status" || r.URL.RawQuery != "" {
t.Error("migration status must use GET without a query string")
}
if r.Header.Get("X-License-Key") != key {
t.Error("migration status must send the key in X-License-Key")
}
})
}
}
18 changes: 9 additions & 9 deletions cmd/commands/license_migrate_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ func runMigrateCheck(ctx context.Context, pingURL string, doc *licenseMigrateDat
status, err := fetchMigrationStatus(ctx, key, pingURL)
if err != nil {
doc.Keys = append(doc.Keys, licenseMigrateKey{Key: masked, Product: pp.DisplayName, Remaining: "?", Status: licenseScrub(fmt.Sprintf("Error: %v", err), migrateKeyFlag)})
tbl.AddRow(masked, pp.DisplayName, "?", fmt.Sprintf("Error: %v", err))
tbl.AddRow(masked, pp.DisplayName, "?", licenseScrub(fmt.Sprintf("Error: %v", err), key))
continue
}

if status.Error != "" {
doc.Keys = append(doc.Keys, licenseMigrateKey{Key: masked, Product: pp.DisplayName, Remaining: "?", Status: licenseScrub(fmt.Sprintf("Error: %s", status.Error), migrateKeyFlag)})
tbl.AddRow(masked, pp.DisplayName, "?", fmt.Sprintf("Error: %s", status.Error))
tbl.AddRow(masked, pp.DisplayName, "?", licenseScrub(fmt.Sprintf("Error: %s", status.Error), key))
continue
}

Expand All @@ -92,7 +92,7 @@ func runMigrateCheck(ctx context.Context, pingURL string, doc *licenseMigrateDat
}

doc.Keys = append(doc.Keys, licenseMigrateKey{Key: masked, Product: pp.DisplayName, Remaining: daysStr, Status: licenseScrub(migStatus, migrateKeyFlag)})
tbl.AddRow(masked, pp.DisplayName, daysStr, migStatus)
tbl.AddRow(masked, pp.DisplayName, daysStr, licenseScrub(migStatus, key))
}

tbl.Render()
Expand Down Expand Up @@ -134,29 +134,29 @@ func runMigrateLink(ctx context.Context, pingURL string, doc *licenseMigrateData
}

masked := license.MaskKey(keyToMigrate)
fmt.Printf("Linking key %s to account %s ...\n", masked, migrateAccountID)
fmt.Printf("Linking key %s to account %s ...\n", masked, licenseScrub(migrateAccountID, keyToMigrate))

resp, err := sendMigrateRequest(ctx, keyToMigrate, migrateAccountID, pingURL)
if err != nil {
return fmt.Errorf("migration request failed: %w", err)
return scrubErr(fmt.Errorf("migration request failed: %w", err))
}

if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
return fmt.Errorf("server error: %s", licenseScrub(resp.Error, keyToMigrate))
}

doc.Link = &licenseMigrateLink{Key: masked, AccountID: licenseScrub(resp.AccountID, keyToMigrate), Tier: licenseScrub(resp.Tier, keyToMigrate), AlreadyLinked: resp.AlreadyMigrated}
if resp.AlreadyMigrated {
fmt.Printf("Key %s is already linked to account %s (tier: %s).\n",
masked, resp.AccountID, resp.Tier)
masked, doc.Link.AccountID, doc.Link.Tier)
return nil
}

if resp.Migrated {
fmt.Printf("Migration complete.\n")
fmt.Printf(" Key: %s\n", masked)
fmt.Printf(" Account: %s\n", resp.AccountID)
fmt.Printf(" Tier: %s\n", resp.Tier)
fmt.Printf(" Account: %s\n", doc.Link.AccountID)
fmt.Printf(" Tier: %s\n", doc.Link.Tier)
fmt.Println()
fmt.Println("Your key is now linked to your nSelf account.")
fmt.Println("Run 'nself license status' to verify.")
Expand Down
5 changes: 3 additions & 2 deletions cmd/commands/license_migrate_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,17 @@ func sendMigrateRequest(ctx context.Context, licenseKey, accountID, pingURL stri
return &result, nil
}

// fetchMigrationStatus — GET /license/migration-status?key=<key>.
// fetchMigrationStatus uses the server-supported X-License-Key header to keep keys out of URLs.
func fetchMigrationStatus(ctx context.Context, key string, pingURL string) (*migrationStatusResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()

url := fmt.Sprintf("%s/license/migration-status?key=%s", pingURL, key)
url := pingURL + "/license/migration-status"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-License-Key", key)

resp, err := httptimeout.License.Do(req)
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions cmd/commands/license_scrub.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ package commands
// Inputs: a document value (any struct, slice, map or pointer tree) or an
// error. Outputs: a copy of the same type with every string leaf scrubbed, or
// an error with scrubbed text and the same classification (code, exit status).
// Constraints: human and v1.4 output are never touched (callers gate on
// jsonEnvelopeOn); a masked key, a UUID and a date pass unchanged; nil slices
// Constraints: document callers gate on jsonEnvelopeOn; human licence
// diagnostics also scrub server text. A masked key, UUID and date pass unchanged; nil slices
// stay nil and empty ones stay empty. SPORT: P7-SURF-16,
// contract:cli.json-envelope

Expand Down
8 changes: 4 additions & 4 deletions cmd/commands/license_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var licenseValidateCmd = &cobra.Command{

masked := license.MaskKey(key)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: validation failed: %v\n", masked, err)
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: validation failed: %v\n", masked, licenseScrub(err.Error(), key))
allValid = false
} else if valid {
validKeys = append(validKeys, masked)
Expand Down Expand Up @@ -244,18 +244,18 @@ Requires network connectivity.`,

result, err := license.RefreshCache(ctx, keys[0])
if err != nil {
return fmt.Errorf("revalidation failed: %w", err)
return scrubErr(fmt.Errorf("revalidation failed: %w", err))
}
if !result.Valid {
return fmt.Errorf("license is invalid: %s", result.Message)
return fmt.Errorf("license is invalid: %s", licenseScrub(result.Message))
}

rev := licenseRevalidateData{Tier: licenseScrub(result.Tier)}
if !result.ExpiresAt.IsZero() {
rev.ExpiresAt = result.ExpiresAt.Format("2006-01-02")
}
setData(cmd, rev)
fmt.Printf("License revalidated. Tier: %s\n", result.Tier)
fmt.Printf("License revalidated. Tier: %s\n", rev.Tier)
if !result.ExpiresAt.IsZero() {
fmt.Printf("Expires: %s\n", result.ExpiresAt.Format("2006-01-02"))
}
Expand Down
4 changes: 4 additions & 0 deletions internal/deploy/remote_backup_confd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ func TestKeptHostConfDDefaultServerCollision(t *testing.T) {
"sites file": {"nginx/sites/other.conf", "server {\n listen 80 default_server;\n}\n", "", "nginx/sites/other.conf", true},
"conf.d-env file": {"nginx/conf.d-prod/x.conf", "server { listen 443 ssl default_server; }\n", "", "nginx/conf.d-prod/x.conf", true},
"conf.d sibling": {"nginx/conf.d/other.conf", "server {\n listen [::]:80 default_server;\n}\n", "", "nginx/conf.d/other.conf", true},
"conf.d backup": {"nginx/conf.d/default.conf.bak", "server { listen 80 default_server; }\n", "", "", false},
"env conf.d backup": {"nginx/conf.d-prod/default.conf.bak", "server { listen 80 default_server; }\n", "", "", false},
"disabled site": {"nginx/sites/default.conf.disabled", "server { listen 80 default_server; }\n", "", "", false},
"site text file": {"nginx/sites/notes.txt", "server { listen 80 default_server; }\n", "", "", false},
"commented out": {"nginx/sites/other.conf", "# listen 80 default_server;\nserver { listen 80; }\n", "", "", false},
"no default_server": {"nginx/sites/other.conf", "server { listen 80; }\n", "", "", false},
"word only": {"nginx/sites/other.conf", "server { server_name default_server; }\n", "", "", false},
Expand Down
4 changes: 2 additions & 2 deletions internal/deploy/remote_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ type KeptConfD struct {
// deploy must not ship (D-0424): the path exists as a regular file with no line
// in its first 5 starting with BuildNginxMarker, or is a symlink or anything that
// is not a regular file. Also, when nginx/conf.d/default.conf is absent on the
// host and any other regular file under nginx/conf.d*/ or nginx/sites/ has a
// host and any other regular .conf file under nginx/conf.d*/ or nginx/sites/ has a
// listen line with default_server, our default.conf is kept out (reason
// DefaultServerIn), since two default servers fail nginx -t. The previous
// deploy's record and the host's shipped list are ignored for conf.d: the host
Expand Down Expand Up @@ -217,7 +217,7 @@ for f in %s; do
if [ -L "$f" ]; then printf 'KEEP %%s\n' "$f"
elif [ -f "$f" ]; then if ! head -n 5 -- "$f" 2>/dev/null | grep -q %s; then printf 'KEEP %%s\n' "$f"; fi
elif [ -e "$f" ]; then printf 'KEEP %%s\n' "$f"
elif [ "$f" = %s ]; then for g in nginx/conf.d*/* nginx/sites/*; do if [ -f "$g" ] && grep -Eq '(^|[{;])[[:space:]]*listen[[:space:]][^#;]*default_server' -- "$g" 2>/dev/null; then printf 'DSRV %%s\t%%s\n' "$f" "$(san "$g")"; break; fi; done
elif [ "$f" = %s ]; then for g in nginx/conf.d*/*.conf nginx/sites/*.conf; do if [ -f "$g" ] && grep -Eq '(^|[{;])[[:space:]]*listen[[:space:]][^#;]*default_server' -- "$g" 2>/dev/null; then printf 'DSRV %%s\t%%s\n' "$f" "$(san "$g")"; break; fi; done
fi
done; exit 0`, q(remotePath), strings.Join(quoteAll(cand), " "), q("^"+strings.ReplaceAll(BuildNginxMarker, " ", "[ ]")), q(defaultServerConf))
out, err := r.Run(ctx, shScript(script))
Expand Down
4 changes: 3 additions & 1 deletion scripts/gen-signed-since.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ floor_of() {
def key: (. // "" | sub("^v"; "") | sub("\\+.*$"; "")) as $v
| ($v | split("-")) as $p
| ($p[0] | split(".") | map(tonumber? // 0)) as $n
| [($n[0] // 0), ($n[1] // 0), ($n[2] // 0), (if ($p | length) > 1 then 0 else 1 end), ($p[1:] | join("-"))];
| ($p[1:] | join("-") | split(".")
| map(if test("^[0-9]+$") then [0, tonumber] else [1, .] end)) as $pre
| [($n[0] // 0), ($n[1] // 0), ($n[2] // 0), (if ($p | length) > 1 then 0 else 1 end), $pre];
([ .[] | (.releases // {}) | to_entries[]
| select(.value.release_signature != null and (.value.version // "") != "")
| {slug: .key, version: .value.version} ]
Expand Down
16 changes: 14 additions & 2 deletions scripts/testdata/signed-since/expected.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
{
"free": {"ci": "1.2.7", "minio": "v1.2.9-rc.1", "tokens": "1.2.9"},
"licensed": {"ai": "1.1.2", "claw": "2.0.0+build7"}
"free": {
"ci": "1.2.7",
"minio": "v1.2.9-rc.1",
"tokens": "1.2.9",
"numeric-prerelease": "1.2.3-rc.2",
"numeric-before-text": "1.2.3-11",
"shorter-prerelease": "1.2.3-alpha",
"hyphen-prerelease": "1.2.3-alpha-beta.2",
"release-after-prerelease": "1.2.3-rc.2+build10"
},
"licensed": {
"ai": "1.1.2",
"claw": "2.0.0+build7"
}
}
Loading
Loading