config.go
| 1 | // Package config reads all settings from environment variables. |
| 2 | // Names and defaults match the table in the README. |
| 3 | package config |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "net/url" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | ) |
| 14 | |
| 15 | type Config struct { |
| 16 | Port int |
| 17 | SSHPort int |
| 18 | DataDir string |
| 19 | OwnerDisplayName string |
| 20 | BaseURL string |
| 21 | PublicHTTPS bool |
| 22 | PublicOrigin string |
| 23 | PublicHost string // host[:port] of BaseURL, what image names start with |
| 24 | RegistrationType string // enabled | disabled | queue |
| 25 | RegisterQuestion string |
| 26 | MaxUploadBytes int64 |
| 27 | MaxUserUploadBytes int64 |
| 28 | InlineMaxBytes int64 |
| 29 | MaxRenderBytes int64 |
| 30 | MaxRawDownloadBytes int64 |
| 31 | SSHDisabled bool |
| 32 | SSHHostKeyPath string |
| 33 | ScannedRepoPrivate bool |
| 34 | TrustedProxy bool |
| 35 | RateLimitDisabled bool |
| 36 | CommitterName string |
| 37 | CommitterEmail string |
| 38 | ExtraAllowedSigners string |
| 39 | MaxTitleBytes int |
| 40 | MaxTextBodyBytes int |
| 41 | MaxUsernameBytes int |
| 42 | MaxPasswordBytes int |
| 43 | CIDockerSocket string |
| 44 | CIMaxHistory int |
| 45 | CIDefaultTimeout int |
| 46 | CIMaxConcurrent int |
| 47 | CIMaxArtifactBytes int64 |
| 48 | CINetwork string // engine network for CI containers, empty = engine default |
| 49 | CIVMImage string // empty = built from the embedded vm/ |
| 50 | CIVMCPUs int |
| 51 | CIVMMemoryMB int64 |
| 52 | CIVMDiskMB int64 |
| 53 | CIMaxImageBytes int64 |
| 54 | RegistryPull string // admin | users | public |
| 55 | MaxConcurrentArchives int |
| 56 | } |
| 57 | |
| 58 | // intEnv returns def when unset or unparsable. min clamps the result; |
| 59 | // pass it where 0 would break the feature instead of disabling it. |
| 60 | func intEnv(key string, def, min int64) int64 { |
| 61 | v := os.Getenv(key) |
| 62 | if v == "" { |
| 63 | return def |
| 64 | } |
| 65 | n, err := strconv.ParseInt(v, 10, 64) |
| 66 | if err != nil { |
| 67 | log.Printf("config: %s=%q is not a number, using %d", key, v, def) |
| 68 | return def |
| 69 | } |
| 70 | if n < min { |
| 71 | return min |
| 72 | } |
| 73 | return n |
| 74 | } |
| 75 | |
| 76 | func strEnv(key, def string) string { |
| 77 | if v := os.Getenv(key); v != "" { |
| 78 | return v |
| 79 | } |
| 80 | return def |
| 81 | } |
| 82 | |
| 83 | // boolEnv treats anything but "", "0" and "false" as true. A value that looks |
| 84 | // like neither is almost always a typo, so it is logged. |
| 85 | func boolEnv(key string) bool { |
| 86 | v := os.Getenv(key) |
| 87 | switch v { |
| 88 | case "", "0", "false", "1", "true": |
| 89 | default: |
| 90 | log.Printf("config: %s=%q is not a boolean, reading it as true", key, v) |
| 91 | } |
| 92 | return v != "" && v != "0" && v != "false" |
| 93 | } |
| 94 | |
| 95 | func Load() (*Config, error) { |
| 96 | port := int(intEnv("PORT", 3000, 1)) |
| 97 | owner := strEnv("OWNER_DISPLAY_NAME", "Admin") |
| 98 | dataDir, err := filepath.Abs(strEnv("DATA_DIR", "./data")) |
| 99 | if err != nil { |
| 100 | return nil, err |
| 101 | } |
| 102 | c := &Config{ |
| 103 | Port: port, |
| 104 | SSHPort: int(intEnv("SSH_PORT", 2222, 1)), |
| 105 | DataDir: dataDir, |
| 106 | OwnerDisplayName: owner, |
| 107 | BaseURL: strEnv("BASE_URL", fmt.Sprintf("http://localhost:%d", port)), |
| 108 | RegistrationType: strEnv("REGISTRATION_TYPE", "enabled"), |
| 109 | RegisterQuestion: os.Getenv("REGISTER_QUESTION"), |
| 110 | MaxUploadBytes: intEnv("MAX_UPLOAD_BYTES", 10<<20, 0), |
| 111 | MaxUserUploadBytes: intEnv("MAX_USER_UPLOAD_BYTES", 2<<20, 0), |
| 112 | InlineMaxBytes: intEnv("INLINE_MAX_BYTES", 512<<10, 0), |
| 113 | MaxRenderBytes: intEnv("MAX_RENDER_BYTES", 10<<20, 0), |
| 114 | MaxRawDownloadBytes: intEnv("MAX_RAW_DOWNLOAD_BYTES", 0, 0), |
| 115 | SSHDisabled: boolEnv("SSH_DISABLED"), |
| 116 | SSHHostKeyPath: strEnv("SSH_HOST_KEY_PATH", filepath.Join(dataDir, "ssh_host_key")), |
| 117 | ScannedRepoPrivate: os.Getenv("SCANNED_REPO_PRIVATE") != "0" && os.Getenv("SCANNED_REPO_PRIVATE") != "false", |
| 118 | TrustedProxy: boolEnv("TRUSTED_PROXY"), |
| 119 | RateLimitDisabled: boolEnv("RATE_LIMIT_DISABLED"), |
| 120 | CommitterName: strEnv("COMMITTER_NAME", owner), |
| 121 | ExtraAllowedSigners: os.Getenv("EXTRA_ALLOWED_SIGNERS_PATH"), |
| 122 | MaxTitleBytes: int(intEnv("MAX_TITLE_BYTES", 500, 0)), |
| 123 | MaxTextBodyBytes: int(intEnv("MAX_TEXT_BODY_BYTES", 100_000, 0)), |
| 124 | MaxUsernameBytes: int(intEnv("MAX_USERNAME_BYTES", 64, 0)), |
| 125 | MaxPasswordBytes: int(intEnv("MAX_PASSWORD_BYTES", 1024, 0)), |
| 126 | CIDockerSocket: os.Getenv("CI_DOCKER_SOCKET"), |
| 127 | CIMaxHistory: int(intEnv("CI_MAX_HISTORY", 50, 1)), |
| 128 | CIDefaultTimeout: int(intEnv("CI_DEFAULT_TIMEOUT", 3600, 1)), |
| 129 | CIMaxConcurrent: int(intEnv("CI_MAX_CONCURRENT", 2, 1)), |
| 130 | CIMaxArtifactBytes: intEnv("CI_MAX_ARTIFACT_BYTES", 512<<20, 1), |
| 131 | CINetwork: os.Getenv("CI_NETWORK"), |
| 132 | CIVMImage: os.Getenv("CI_VM_IMAGE"), |
| 133 | CIVMCPUs: int(intEnv("CI_VM_CPUS", 2, 1)), |
| 134 | CIVMMemoryMB: intEnv("CI_VM_MEMORY_MB", 2048, 1024), |
| 135 | CIVMDiskMB: intEnv("CI_VM_DISK_MB", 20480, 1024), |
| 136 | CIMaxImageBytes: intEnv("CI_MAX_IMAGE_BYTES", 4<<30, 1), |
| 137 | RegistryPull: strEnv("REGISTRY_PULL", "admin"), |
| 138 | MaxConcurrentArchives: int(intEnv("MAX_CONCURRENT_ARCHIVE_JOBS", 2, 1)), |
| 139 | } |
| 140 | switch c.RegistrationType { |
| 141 | case "enabled", "disabled", "queue": |
| 142 | default: |
| 143 | return nil, fmt.Errorf("REGISTRATION_TYPE %q must be enabled, disabled or queue", c.RegistrationType) |
| 144 | } |
| 145 | switch c.RegistryPull { |
| 146 | case "admin", "users", "public": |
| 147 | default: |
| 148 | return nil, fmt.Errorf("REGISTRY_PULL %q must be admin, users or public", c.RegistryPull) |
| 149 | } |
| 150 | u, err := url.Parse(c.BaseURL) |
| 151 | if err != nil || u.Host == "" { |
| 152 | return nil, fmt.Errorf("BASE_URL %q is not a valid URL", c.BaseURL) |
| 153 | } |
| 154 | c.PublicHTTPS = u.Scheme == "https" |
| 155 | // Browsers send Origin in this canonical form. The CSRF check and |
| 156 | // WebAuthn compare it verbatim. |
| 157 | host := strings.ToLower(u.Host) |
| 158 | switch u.Scheme { |
| 159 | case "https": |
| 160 | host = strings.TrimSuffix(host, ":443") |
| 161 | case "http": |
| 162 | host = strings.TrimSuffix(host, ":80") |
| 163 | } |
| 164 | c.PublicOrigin = u.Scheme + "://" + host |
| 165 | c.PublicHost = host |
| 166 | c.CommitterEmail = strEnv("COMMITTER_EMAIL", owner+"@"+u.Hostname()) |
| 167 | return c, nil |
| 168 | } |
| 169 | |
| 170 | // CanPullImages applies REGISTRY_PULL. Images of private repositories are |
| 171 | // admin-only whatever the setting says. |
| 172 | func (c *Config) CanPullImages(isPrivate, authed, isAdmin bool) bool { |
| 173 | if isAdmin { |
| 174 | return true |
| 175 | } |
| 176 | if isPrivate { |
| 177 | return false |
| 178 | } |
| 179 | return c.RegistryPull == "public" || (c.RegistryPull == "users" && authed) |
| 180 | } |
| 181 | |
| 182 | // Derived paths under DataDir. |
| 183 | func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "hearthforge.db") } |
| 184 | func (c *Config) ReposDir() string { return filepath.Join(c.DataDir, "repos") } |
| 185 | func (c *Config) AvatarsDir() string { return filepath.Join(c.DataDir, "avatars") } |
| 186 | func (c *Config) ReleasesDir() string { return filepath.Join(c.DataDir, "releases") } |
| 187 | func (c *Config) AllowedSignersPath() string { return filepath.Join(c.DataDir, "allowed_signers") } |
| 188 | func (c *Config) CIArtifactsDir() string { return filepath.Join(c.DataDir, "ci", "artifacts") } |
| 189 | func (c *Config) RegistryDir() string { return filepath.Join(c.DataDir, "registry") } |
| 190 |