env fix
This commit is contained in:
17
.env.secrets
Normal file
17
.env.secrets
Normal file
@@ -0,0 +1,17 @@
|
||||
# Secrets for LOCAL runs. Git ignores this file — that is the whole point of it.
|
||||
#
|
||||
# Every other .env file in this folder is tracked, so a key written there is a
|
||||
# key pushed to the repository. This one is not, so it is where a key goes.
|
||||
#
|
||||
# It does NOT reach production. The container takes its environment from Dokploy
|
||||
# and no .env file is copied into the image, so these same three names have to be
|
||||
# set again in Dokploy → your backend application → Environment → Redeploy.
|
||||
|
||||
# Nearle Buddy. Three variables and nothing else is needed; ASSISTANT_PROVIDER is
|
||||
# worked out from the model name.
|
||||
ASSISTANT_BASE_URL=https://api.groq.com/openai/v1
|
||||
ASSISTANT_MODEL=openai/gpt-oss-120b
|
||||
|
||||
# Paste your Groq key here, from https://console.groq.com/keys
|
||||
# The one used while building this is in the chat history and should be replaced.
|
||||
ASSISTANT_API_KEY=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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -54,3 +54,8 @@ Thumbs.db
|
||||
# that getting worse, but the existing history still has them and the password
|
||||
# should be rotated.
|
||||
|
||||
|
||||
# Secrets, for local runs only. Never committed — the rule below is what makes
|
||||
# that true, and it is why this file exists separately from .env.local, which
|
||||
# IS tracked and therefore cannot hold a key.
|
||||
|
||||
|
||||
@@ -89,3 +89,45 @@ func TestTheTierFallbackDoesNotHideAMissingModel(t *testing.T) {
|
||||
t.Fatal("balanced resolved to something despite being unset")
|
||||
}
|
||||
}
|
||||
|
||||
// Where a secret is allowed to live.
|
||||
//
|
||||
// `.env`, `.env.local` and `.env.production` are all tracked by git, so a key
|
||||
// written to any of them is a key published. There was nowhere else, and the
|
||||
// standing instruction was to export it in the shell on every run — which is
|
||||
// the kind of instruction people route around by editing a tracked file.
|
||||
func TestASecretsFileIsReadBeforeAnyTrackedEnvFile(t *testing.T) {
|
||||
order := envFileOrder("local")
|
||||
|
||||
if len(order) == 0 || order[0] != ".env.secrets" {
|
||||
t.Fatalf(".env.secrets is not read first, so a tracked file wins: %v", order)
|
||||
}
|
||||
// godotenv does not overwrite, so being first IS what makes it authoritative.
|
||||
// Being merely present would let .env.local decide the key instead.
|
||||
for _, tracked := range []string{".env.local", ".env"} {
|
||||
for i, name := range order {
|
||||
if name == tracked && i == 0 {
|
||||
t.Fatalf("%s is read first; a secret there would be committed", tracked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEnvironmentsOwnFileBeatsTheSharedOne(t *testing.T) {
|
||||
// `.env.production` must be consulted before the shared `.env`, or a
|
||||
// production deployment silently takes the local defaults.
|
||||
order := envFileOrder("production")
|
||||
|
||||
var production, shared int = -1, -1
|
||||
for i, name := range order {
|
||||
switch name {
|
||||
case ".env.production":
|
||||
production = i
|
||||
case ".env":
|
||||
shared = i
|
||||
}
|
||||
}
|
||||
if production < 0 || shared < 0 || production > shared {
|
||||
t.Fatalf("the environment's own file does not take precedence: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,10 +465,21 @@ func (c *Config) validate() error {
|
||||
//
|
||||
// APP_ENV is read from the real environment before any file, so a file cannot
|
||||
// change which environment it is loaded for.
|
||||
func loadEnvFiles() {
|
||||
appEnv := env("APP_ENV", EnvLocal)
|
||||
// `.env.secrets` is read FIRST and is the only one of these git does not track.
|
||||
// godotenv never overwrites a value already set, so first read wins — which is
|
||||
// what makes this file the place a key belongs. Every other file here is in the
|
||||
// repository, so a secret written to one is a secret published; there was
|
||||
// previously nowhere to put a key at all, and the answer was "export it in your
|
||||
// shell every time", which is the kind of instruction people route around.
|
||||
// envFileOrder is the read order, and the order is the rule: godotenv never
|
||||
// overwrites a value already set, so whichever file names a variable first is
|
||||
// the one that decides it.
|
||||
func envFileOrder(appEnv string) []string {
|
||||
return []string{".env.secrets", ".env." + appEnv, ".env"}
|
||||
}
|
||||
|
||||
for _, name := range []string{".env." + appEnv, ".env"} {
|
||||
func loadEnvFiles() {
|
||||
for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) {
|
||||
if _, err := os.Stat(name); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
81
scratch/buddystatus/main.go
Normal file
81
scratch/buddystatus/main.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Asks the deployed server whether Nearle Buddy has a model.
|
||||
//
|
||||
// go run ./scratch/buddystatus # production
|
||||
// go run ./scratch/buddystatus http://localhost:1122
|
||||
//
|
||||
// `/assistant/status` sits behind the session guard, so this mints one. That it
|
||||
// CAN mint one, from a secret sitting in a tracked file, is itself the finding
|
||||
// recorded in middleware/webauth.go: anybody with repository access can issue a
|
||||
// session for any tenant. Rotating POS_TOKEN_SECRET out of `.env.local` is the
|
||||
// fix, and this tool stops working the day that happens — which is correct.
|
||||
//
|
||||
// Read-only. It asks one question and prints the answer.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// The secret lives in the env files, not in this program.
|
||||
_ = godotenv.Load(".env.local")
|
||||
_ = godotenv.Load(".env")
|
||||
|
||||
host := "https://fiesta.nearle.app"
|
||||
if len(os.Args) > 1 {
|
||||
host = strings.TrimRight(os.Args[1], "/")
|
||||
}
|
||||
|
||||
token, _, err := utils.MintWebToken(utils.WebClaims{Userid: 904, Tenantid: 1147}, time.Now())
|
||||
if err != nil {
|
||||
fmt.Println("cannot mint a session:", err)
|
||||
fmt.Println("POS_TOKEN_SECRET is not set here, or is shorter than 16 characters.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
url := host + "/live/api/v1/web/assistant/status"
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("could not reach", url, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("%s\nHTTP %d\n%s\n\n", url, resp.StatusCode, body)
|
||||
|
||||
var envelope struct {
|
||||
Details struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if json.Unmarshal(body, &envelope) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
fmt.Println("The session was refused — this deployment signs with a different secret.")
|
||||
case envelope.Details.Available:
|
||||
fmt.Println("Buddy has a model. The composer should accept a question on Console, Sales and Inventory.")
|
||||
case envelope.Details.Reason != "":
|
||||
fmt.Println("Buddy is off:", envelope.Details.Reason)
|
||||
default:
|
||||
fmt.Println("Buddy is off. This build does not say why — ASSISTANT_BASE_URL, ASSISTANT_MODEL")
|
||||
fmt.Println("and ASSISTANT_API_KEY are what it needs, set on the platform and redeployed.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user