feat(t229): load ERP credentials from dotenv
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Copy this file to .env and fill it locally. Do not commit .env.
|
||||
CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com
|
||||
CMROUBAO_SHUNYUNBAO_USERNAME=
|
||||
CMROUBAO_SHUNYUNBAO_PASSWORD=
|
||||
@@ -1,5 +1,6 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
bin/
|
||||
var/
|
||||
*.db
|
||||
|
||||
+22
-2
@@ -24,8 +24,28 @@ start/运行续租/release 和取消安全确认。
|
||||
| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` |
|
||||
| `CMROUBAO_RUNNING_LEASE` | `30m` | RUNNING/等待确认的离线执行授权;允许 `5m` 至 `120m` |
|
||||
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
|
||||
| `CMROUBAO_SHUNYUNBAO_URL` | `https://www.shunyunbaoerp.com` | 顺运宝 HTTPS origin |
|
||||
| `CMROUBAO_SHUNYUNBAO_USERNAME` | 无 | 顺运宝账号;必须与密码同时设置 |
|
||||
| `CMROUBAO_SHUNYUNBAO_PASSWORD` | 无 | 顺运宝密码;必须与账号同时设置 |
|
||||
|
||||
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
||||
### 本地 ERP `.env`
|
||||
|
||||
API 启动时只从当前目录读取 `.env`,标准启动脚本会先进入 `backend-api/`,因此文件位置
|
||||
固定为 `backend-api/.env`。首次配置:
|
||||
|
||||
```powershell
|
||||
Set-Location backend-api
|
||||
Copy-Item .env.example .env
|
||||
# 编辑 .env,填入顺运宝账号和密码
|
||||
```
|
||||
|
||||
该文件只允许上述三个 `CMROUBAO_SHUNYUNBAO_*` 值,进程环境变量优先于同名 `.env` 值。
|
||||
它不配置数据库、监听/TLS、`authctl` 密码或其他应用选项,也不会修改全局进程环境。缺失
|
||||
`.env` 时 ERP 保持未配置,其他本地功能仍可启动。仅支持空行、整行 `#` 注释和 `KEY=VALUE`
|
||||
(需要保留空格或 `#` 的值可使用成对单/双引号);不支持变量展开、命令或行内注释。
|
||||
|
||||
`.env` 已被 Git 忽略,应只保存在本机受限目录;`.env.example` 不得填入真实凭证。`var/`
|
||||
运行数据同样不得提交。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -61,7 +81,7 @@ API 启动前会检查全部 migration 已应用;发现 pending migration 会
|
||||
执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。
|
||||
|
||||
`authctl create-user` 和 `authctl reset-password` 的密码只从
|
||||
`CMROUBAO_AUTH_PASSWORD` 读取,不接受命令行密码。
|
||||
`CMROUBAO_AUTH_PASSWORD` 进程环境变量读取,不接受命令行密码或 ERP `.env`。
|
||||
`create-device` 只在成功时输出一次设备 ID 和 256 bit 设备 token;原值应立即放入
|
||||
设备安全配置,不得写入 Git、普通日志或共享文档。`enable-user`、`disable-user`、
|
||||
`enable-device`、`disable-device` 是受支持的本地停用/恢复入口;禁用会让该主体的
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
`go-blueprint create --name backend-api --framework gin --driver sqlite --git skip`
|
||||
|
||||
The generator was run once outside this repository. Its current output targets Go 1.25 and
|
||||
Gin 1.12 and includes demo routes, permissive CORS, implicit `.env` loading, a package-level
|
||||
Gin 1.12 and includes demo routes, permissive CORS, a generic implicit `.env` loader, a package-level
|
||||
database singleton, and fatal health-check behavior. Those defaults were not copied. The checked-in
|
||||
code retains only the minimal command/internal package shape and is independently constrained by
|
||||
this project's architecture and tests.
|
||||
this project's architecture and tests. `cmd/api` separately uses a bounded, allowlisted `.env`
|
||||
fallback for the three ERP configuration values only; it does not provide generic application
|
||||
configuration loading.
|
||||
|
||||
@@ -34,7 +34,7 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load(os.LookupEnv)
|
||||
cfg, err := loadConfig(os.LookupEnv, config.DefaultERPEnvironmentFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,6 +93,20 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(
|
||||
lookup config.LookupEnvironment,
|
||||
environmentFile string,
|
||||
) (config.Config, error) {
|
||||
effectiveLookup, err := config.WithERPEnvironmentFile(
|
||||
environmentFile,
|
||||
lookup,
|
||||
)
|
||||
if err != nil {
|
||||
return config.Config{}, err
|
||||
}
|
||||
return config.Load(effectiveLookup)
|
||||
}
|
||||
|
||||
func shutdownServer(
|
||||
server *http.Server,
|
||||
serverErrors <-chan error,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,6 +17,40 @@ import (
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
)
|
||||
|
||||
func TestLoadConfigUsesERPEnvironmentFile(t *testing.T) {
|
||||
environmentFile := filepath.Join(t.TempDir(), ".env")
|
||||
err := os.WriteFile(environmentFile, []byte(
|
||||
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test\n"+
|
||||
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user\n"+
|
||||
"CMROUBAO_SHUNYUNBAO_PASSWORD=dotenv-password\n",
|
||||
), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(func(name string) (string, bool) {
|
||||
if name == config.ShunyunbaoUsernameEnvironment {
|
||||
return "process-user", true
|
||||
}
|
||||
return "", false
|
||||
}, environmentFile)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
||||
cfg.ShunyunbaoUsername != "process-user" ||
|
||||
cfg.ShunyunbaoPassword != "dotenv-password" {
|
||||
t.Fatalf(
|
||||
"ERP config = %#v",
|
||||
struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
}{cfg.ShunyunbaoURL, cfg.ShunyunbaoUsername, cfg.ShunyunbaoPassword},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownServerForceClosesAfterGracefulTimeout(t *testing.T) {
|
||||
handlerStarted := make(chan struct{})
|
||||
releaseHandler := make(chan struct{})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultERPEnvironmentFile = ".env"
|
||||
|
||||
maximumERPEnvironmentFileBytes = 32 << 10
|
||||
maximumERPEnvironmentLineBytes = 4 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
errERPEnvironmentFileInvalid = errors.New("ERP .env file is invalid")
|
||||
errERPEnvironmentLookup = errors.New("ERP .env lookup is required")
|
||||
)
|
||||
|
||||
// WithERPEnvironmentFile returns a lookup that uses the process environment
|
||||
// first and only falls back to approved ERP values from path.
|
||||
func WithERPEnvironmentFile(
|
||||
path string,
|
||||
parent LookupEnvironment,
|
||||
) (LookupEnvironment, error) {
|
||||
if parent == nil {
|
||||
return nil, errERPEnvironmentLookup
|
||||
}
|
||||
values, err := readERPEnvironmentFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(name string) (string, bool) {
|
||||
if value, exists := parent(name); exists {
|
||||
return value, true
|
||||
}
|
||||
value, exists := values[name]
|
||||
return value, exists
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readERPEnvironmentFile(path string) (map[string]string, error) {
|
||||
values := make(map[string]string)
|
||||
if path == "" {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return values, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("ERP .env file cannot be read")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contents, err := io.ReadAll(io.LimitReader(
|
||||
file,
|
||||
maximumERPEnvironmentFileBytes+1,
|
||||
))
|
||||
if err != nil || len(contents) > maximumERPEnvironmentFileBytes ||
|
||||
!utf8.Valid(contents) || strings.IndexByte(string(contents), 0) >= 0 {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
|
||||
for _, rawLine := range strings.Split(string(contents), "\n") {
|
||||
line := strings.TrimSuffix(rawLine, "\r")
|
||||
if len(line) > maximumERPEnvironmentLineBytes {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
name, rawValue, found := strings.Cut(line, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if !found || !validEnvironmentName(name) {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
if !isERPEnvironmentName(name) {
|
||||
if strings.HasPrefix(name, "CMROUBAO_") {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, exists := values[name]; exists {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
value, err := dotenvValue(rawValue)
|
||||
if err != nil {
|
||||
return nil, errERPEnvironmentFileInvalid
|
||||
}
|
||||
values[name] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func dotenvValue(rawValue string) (string, error) {
|
||||
value := strings.TrimSpace(rawValue)
|
||||
if value == "" {
|
||||
return value, nil
|
||||
}
|
||||
quote := value[0]
|
||||
if quote == '\'' || quote == '"' {
|
||||
if len(value) < 2 || value[len(value)-1] != quote {
|
||||
return "", errERPEnvironmentFileInvalid
|
||||
}
|
||||
return value[1 : len(value)-1], nil
|
||||
}
|
||||
if strings.HasSuffix(value, "\"") || strings.HasSuffix(value, "'") {
|
||||
return "", errERPEnvironmentFileInvalid
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func isERPEnvironmentName(name string) bool {
|
||||
switch name {
|
||||
case ShunyunbaoURLEnvironment,
|
||||
ShunyunbaoUsernameEnvironment,
|
||||
ShunyunbaoPasswordEnvironment:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validEnvironmentName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < len(name); index++ {
|
||||
character := name[index]
|
||||
if (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9' && index > 0) ||
|
||||
character == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
|
||||
path := writeERPEnvironmentFile(t, strings.Join([]string{
|
||||
"OTHER_TOOL_TOKEN=ignored",
|
||||
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test",
|
||||
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
||||
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
|
||||
}, "\n"))
|
||||
|
||||
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithERPEnvironmentFile() error = %v", err)
|
||||
}
|
||||
cfg, err := Load(lookup)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
||||
cfg.ShunyunbaoUsername != "dotenv-user" ||
|
||||
cfg.ShunyunbaoPassword != "dotenv password #1" {
|
||||
t.Fatalf(
|
||||
"ERP config = %#v",
|
||||
struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
}{cfg.ShunyunbaoURL, cfg.ShunyunbaoUsername, cfg.ShunyunbaoPassword},
|
||||
)
|
||||
}
|
||||
if _, exists := lookup("OTHER_TOOL_TOKEN"); exists {
|
||||
t.Fatal("unapproved .env value was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithERPEnvironmentFileProcessEnvironmentTakesPriority(t *testing.T) {
|
||||
path := writeERPEnvironmentFile(t, strings.Join([]string{
|
||||
"CMROUBAO_SHUNYUNBAO_URL=https://dotenv.example.test",
|
||||
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
||||
"CMROUBAO_SHUNYUNBAO_PASSWORD=dotenv-password",
|
||||
}, "\n"))
|
||||
process := map[string]string{
|
||||
ShunyunbaoURLEnvironment: "https://process.example.test",
|
||||
ShunyunbaoUsernameEnvironment: "process-user",
|
||||
ShunyunbaoPasswordEnvironment: "process-password",
|
||||
}
|
||||
lookup, err := WithERPEnvironmentFile(path, lookupMap(process))
|
||||
if err != nil {
|
||||
t.Fatalf("WithERPEnvironmentFile() error = %v", err)
|
||||
}
|
||||
cfg, err := Load(lookup)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.ShunyunbaoURL != process[ShunyunbaoURLEnvironment] ||
|
||||
cfg.ShunyunbaoUsername != process[ShunyunbaoUsernameEnvironment] ||
|
||||
cfg.ShunyunbaoPassword != process[ShunyunbaoPasswordEnvironment] {
|
||||
t.Fatalf("process values did not override .env values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithERPEnvironmentFileAllowsMissingFile(t *testing.T) {
|
||||
lookup, err := WithERPEnvironmentFile(
|
||||
filepath.Join(t.TempDir(), "missing.env"),
|
||||
lookupMap(nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WithERPEnvironmentFile() error = %v", err)
|
||||
}
|
||||
if _, exists := lookup(ShunyunbaoUsernameEnvironment); exists {
|
||||
t.Fatal("missing file provided a value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithERPEnvironmentFileRejectsInvalidContentWithoutLeakingValues(t *testing.T) {
|
||||
const secret = "must-not-appear-in-error"
|
||||
testCases := map[string]string{
|
||||
"invalid name": "not a name=" + secret,
|
||||
"duplicate": strings.Join([]string{
|
||||
"CMROUBAO_SHUNYUNBAO_USERNAME=" + secret,
|
||||
"CMROUBAO_SHUNYUNBAO_USERNAME=another",
|
||||
}, "\n"),
|
||||
"unknown app config": "CMROUBAO_AUTH_PASSWORD=" + secret,
|
||||
"unmatched quote": "CMROUBAO_SHUNYUNBAO_PASSWORD='" + secret,
|
||||
"nul": "CMROUBAO_SHUNYUNBAO_PASSWORD=" + secret + "\x00",
|
||||
}
|
||||
for name, contents := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := writeERPEnvironmentFile(t, contents)
|
||||
_, err := WithERPEnvironmentFile(path, lookupMap(nil))
|
||||
if err == nil {
|
||||
t.Fatal("WithERPEnvironmentFile() error = nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("error leaked .env content: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithERPEnvironmentFileRejectsOversizedFile(t *testing.T) {
|
||||
path := writeERPEnvironmentFile(
|
||||
t,
|
||||
"CMROUBAO_SHUNYUNBAO_USERNAME="+
|
||||
strings.Repeat("x", maximumERPEnvironmentFileBytes),
|
||||
)
|
||||
_, err := WithERPEnvironmentFile(path, lookupMap(nil))
|
||||
if err == nil {
|
||||
t.Fatal("WithERPEnvironmentFile() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func writeERPEnvironmentFile(t *testing.T, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func lookupMap(values map[string]string) LookupEnvironment {
|
||||
return func(name string) (string, bool) {
|
||||
value, exists := values[name]
|
||||
return value, exists
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user