github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/ddevapp/traefik.go (about) 1 package ddevapp 2 3 import ( 4 "fmt" 5 "os" 6 "path" 7 "path/filepath" 8 "strings" 9 "text/template" 10 11 "github.com/ddev/ddev/pkg/dockerutil" 12 "github.com/ddev/ddev/pkg/exec" 13 "github.com/ddev/ddev/pkg/fileutil" 14 "github.com/ddev/ddev/pkg/globalconfig" 15 "github.com/ddev/ddev/pkg/nodeps" 16 "github.com/ddev/ddev/pkg/util" 17 ) 18 19 type TraefikRouting struct { 20 ExternalHostnames []string 21 ExternalPort string 22 InternalServiceName string 23 InternalServicePort string 24 HTTPS bool 25 } 26 27 // detectAppRouting reviews the configured services and uses their 28 // VIRTUAL_HOST and HTTP(S)_EXPOSE environment variables to set up routing 29 // for the project 30 func detectAppRouting(app *DdevApp) ([]TraefikRouting, error) { 31 // app.ComposeYaml["services"]; 32 var table []TraefikRouting 33 if services, ok := app.ComposeYaml["services"]; ok { 34 for serviceName, s := range services.(map[string]interface{}) { 35 service := s.(map[string]interface{}) 36 if env, ok := service["environment"].(map[string]interface{}); ok { 37 var virtualHost string 38 var ok bool 39 if virtualHost, ok = env["VIRTUAL_HOST"].(string); ok { 40 util.Debug("VIRTUAL_HOST=%v for %s", virtualHost, serviceName) 41 } 42 if virtualHost == "" { 43 continue 44 } 45 hostnames := strings.Split(virtualHost, ",") 46 if httpExpose, ok := env["HTTP_EXPOSE"].(string); ok && httpExpose != "" { 47 util.Debug("HTTP_EXPOSE=%v for %s", httpExpose, serviceName) 48 routeEntries, err := processHTTPExpose(serviceName, httpExpose, false, hostnames) 49 if err != nil { 50 return nil, err 51 } 52 table = append(table, routeEntries...) 53 } 54 55 if httpsExpose, ok := env["HTTPS_EXPOSE"].(string); ok && httpsExpose != "" { 56 util.Debug("HTTPS_EXPOSE=%v for %s", httpsExpose, serviceName) 57 routeEntries, err := processHTTPExpose(serviceName, httpsExpose, true, hostnames) 58 if err != nil { 59 return nil, err 60 } 61 table = append(table, routeEntries...) 62 } 63 } 64 } 65 } 66 return table, nil 67 } 68 69 // processHTTPExpose creates routing table entry from VIRTUAL_HOST and HTTP(S)_EXPOSE 70 // environment variables 71 func processHTTPExpose(serviceName string, httpExpose string, isHTTPS bool, externalHostnames []string) ([]TraefikRouting, error) { 72 var routingTable []TraefikRouting 73 portPairs := strings.Split(httpExpose, ",") 74 for _, portPair := range portPairs { 75 ports := strings.Split(portPair, ":") 76 if len(ports) == 0 || len(ports) > 2 { 77 util.Warning("Skipping bad HTTP_EXPOSE port pair spec %s for service %s", portPair, serviceName) 78 continue 79 } 80 if len(ports) == 1 { 81 ports = append(ports, ports[0]) 82 } 83 routingTable = append(routingTable, TraefikRouting{ExternalHostnames: externalHostnames, ExternalPort: ports[0], InternalServiceName: serviceName, InternalServicePort: ports[1], HTTPS: isHTTPS}) 84 } 85 return routingTable, nil 86 } 87 88 // pushGlobalTraefikConfig pushes the config into ddev-global-cache 89 func pushGlobalTraefikConfig() error { 90 globalTraefikDir := filepath.Join(globalconfig.GetGlobalDdevDir(), "traefik") 91 err := os.MkdirAll(globalTraefikDir, 0755) 92 if err != nil { 93 return fmt.Errorf("failed to create global .ddev/traefik directory: %v", err) 94 } 95 sourceCertsPath := filepath.Join(globalTraefikDir, "certs") 96 // SourceConfigDir for dynamic config 97 sourceConfigDir := filepath.Join(globalTraefikDir, "config") 98 targetCertsPath := path.Join("/mnt/ddev-global-cache/traefik/certs") 99 100 err = os.MkdirAll(sourceCertsPath, 0755) 101 if err != nil { 102 return fmt.Errorf("failed to create global Traefik certs dir: %v", err) 103 } 104 err = os.MkdirAll(sourceConfigDir, 0755) 105 if err != nil { 106 return fmt.Errorf("failed to create global Traefik config dir: %v", err) 107 } 108 109 // Assume that the #ddev-generated exists in file unless it doesn't 110 sigExists := true 111 for _, pemFile := range []string{"default_cert.crt", "default_key.key"} { 112 origFile := filepath.Join(sourceCertsPath, pemFile) 113 if fileutil.FileExists(origFile) { 114 // Check to see if file has #ddev-generated in it, meaning we can recreate it. 115 sigExists, err = fileutil.FgrepStringInFile(origFile, nodeps.DdevFileSignature) 116 if err != nil { 117 return err 118 } 119 // If either of the files has #ddev-generated, we will respect both 120 if !sigExists { 121 break 122 } 123 } 124 } 125 if sigExists && globalconfig.DdevGlobalConfig.MkcertCARoot != "" { 126 c := []string{"--cert-file", filepath.Join(sourceCertsPath, "default_cert.crt"), "--key-file", filepath.Join(sourceCertsPath, "default_key.key"), "127.0.0.1", "localhost", "*.ddev.local", "ddev-router", "ddev-router.ddev", "ddev-router.ddev_default", "*.ddev.site"} 127 if globalconfig.DdevGlobalConfig.ProjectTldGlobal != "" { 128 c = append(c, "*."+globalconfig.DdevGlobalConfig.ProjectTldGlobal) 129 } 130 131 out, err := exec.RunHostCommand("mkcert", c...) 132 if err != nil { 133 util.Failed("failed to create global mkcert certificate, check mkcert operation: %v", out) 134 } 135 136 // Prepend #ddev-generated in generated crt and key files 137 for _, pemFile := range []string{"default_cert.crt", "default_key.key"} { 138 origFile := filepath.Join(sourceCertsPath, pemFile) 139 140 contents, err := fileutil.ReadFileIntoString(origFile) 141 if err != nil { 142 return fmt.Errorf("failed to read file %v: %v", origFile, err) 143 } 144 contents = nodeps.DdevFileSignature + "\n" + contents 145 err = fileutil.TemplateStringToFile(contents, nil, origFile) 146 if err != nil { 147 return err 148 } 149 } 150 } 151 152 type traefikData struct { 153 App *DdevApp 154 Hostnames []string 155 PrimaryHostname string 156 TargetCertsPath string 157 RouterPorts []string 158 UseLetsEncrypt bool 159 LetsEncryptEmail string 160 TraefikMonitorPort string 161 } 162 templateData := traefikData{ 163 TargetCertsPath: targetCertsPath, 164 RouterPorts: determineRouterPorts(), 165 UseLetsEncrypt: globalconfig.DdevGlobalConfig.UseLetsEncrypt, 166 LetsEncryptEmail: globalconfig.DdevGlobalConfig.LetsEncryptEmail, 167 TraefikMonitorPort: globalconfig.DdevGlobalConfig.TraefikMonitorPort, 168 } 169 170 traefikYamlFile := filepath.Join(sourceConfigDir, "default_config.yaml") 171 sigExists = true 172 // TODO: Systematize this checking-for-signature, allow an arg to skip if empty 173 fi, err := os.Stat(traefikYamlFile) 174 // Don't use simple fileutil.FileExists() because of the danger of an empty file 175 if err == nil && fi.Size() > 0 { 176 // Check to see if file has #ddev-generated in it, meaning we can recreate it. 177 sigExists, err = fileutil.FgrepStringInFile(traefikYamlFile, nodeps.DdevFileSignature) 178 if err != nil { 179 return err 180 } 181 } 182 if !sigExists { 183 util.Debug("Not creating %s because it exists and is managed by user", traefikYamlFile) 184 } else { 185 f, err := os.Create(traefikYamlFile) 186 if err != nil { 187 util.Failed("Failed to create Traefik config file: %v", err) 188 } 189 defer f.Close() 190 t, err := template.New("traefik_global_config_template.yaml").Funcs(getTemplateFuncMap()).ParseFS(bundledAssets, "traefik_global_config_template.yaml") 191 if err != nil { 192 return fmt.Errorf("could not create template from traefik_global_config_template.yaml: %v", err) 193 } 194 195 err = t.Execute(f, templateData) 196 if err != nil { 197 return fmt.Errorf("could not parse traefik_global_config_template.yaml with templatedate='%v':: %v", templateData, err) 198 } 199 } 200 201 // sourceConfigDir for static config 202 sourceConfigDir = globalTraefikDir 203 traefikYamlFile = filepath.Join(sourceConfigDir, "static_config.yaml") 204 sigExists = true 205 // TODO: Systematize this checking-for-signature, allow an arg to skip if empty 206 fi, err = os.Stat(traefikYamlFile) 207 // Don't use simple fileutil.FileExists() because of the danger of an empty file 208 if err == nil && fi.Size() > 0 { 209 // Check to see if file has #ddev-generated in it, meaning we can recreate it. 210 sigExists, err = fileutil.FgrepStringInFile(traefikYamlFile, nodeps.DdevFileSignature) 211 if err != nil { 212 return err 213 } 214 } 215 if !sigExists { 216 util.Debug("Not creating %s because it exists and is managed by user", traefikYamlFile) 217 } else { 218 f, err := os.Create(traefikYamlFile) 219 if err != nil { 220 util.Failed("Failed to create Traefik config file: %v", err) 221 } 222 t, err := template.New("traefik_static_config_template.yaml").Funcs(getTemplateFuncMap()).ParseFS(bundledAssets, "traefik_static_config_template.yaml") 223 if err != nil { 224 return fmt.Errorf("could not create template from traefik_static_config_template.yaml: %v", err) 225 } 226 227 err = t.Execute(f, templateData) 228 if err != nil { 229 return fmt.Errorf("could not parse traefik_global_config_template.yaml with templatedate='%v':: %v", templateData, err) 230 } 231 } 232 uid, _, _ := util.GetContainerUIDGid() 233 234 err = dockerutil.CopyIntoVolume(globalTraefikDir, "ddev-global-cache", "traefik", uid, "", false) 235 if err != nil { 236 return fmt.Errorf("failed to copy global Traefik config into Docker volume ddev-global-cache/traefik: %v", err) 237 } 238 util.Debug("Copied global Traefik config in %s to ddev-global-cache/traefik", sourceCertsPath) 239 240 return nil 241 } 242 243 // configureTraefikForApp configures the dynamic configuration and creates cert+key 244 // in .ddev/traefik 245 func configureTraefikForApp(app *DdevApp) error { 246 routingTable, err := detectAppRouting(app) 247 if err != nil { 248 return err 249 } 250 251 // hostnames here should be used only for creating the cert. 252 hostnames := app.GetHostnames() 253 // There can possibly be VIRTUAL_HOST entries which are not configured hostnames. 254 for _, r := range routingTable { 255 if r.ExternalHostnames != nil { 256 hostnames = append(hostnames, r.ExternalHostnames...) 257 } 258 } 259 hostnames = util.SliceToUniqueSlice(&hostnames) 260 projectTraefikDir := app.GetConfigPath("traefik") 261 err = os.MkdirAll(projectTraefikDir, 0755) 262 if err != nil { 263 return fmt.Errorf("failed to create .ddev/traefik directory: %v", err) 264 } 265 sourceCertsPath := filepath.Join(projectTraefikDir, "certs") 266 sourceConfigDir := filepath.Join(projectTraefikDir, "config") 267 targetCertsPath := path.Join("/mnt/ddev-global-cache/traefik/certs") 268 customCertsPath := app.GetConfigPath("custom_certs") 269 270 err = os.MkdirAll(sourceCertsPath, 0755) 271 if err != nil { 272 return fmt.Errorf("failed to create Traefik certs dir: %v", err) 273 } 274 err = os.MkdirAll(sourceConfigDir, 0755) 275 if err != nil { 276 return fmt.Errorf("failed to create Traefik config dir: %v", err) 277 } 278 279 baseName := filepath.Join(sourceCertsPath, app.Name) 280 // Assume that the #ddev-generated exists in file unless it doesn't 281 sigExists := true 282 for _, pemFile := range []string{app.Name + ".crt", app.Name + ".key"} { 283 origFile := filepath.Join(sourceCertsPath, pemFile) 284 if fileutil.FileExists(origFile) { 285 // Check to see if file has #ddev-generated in it, meaning we can recreate it. 286 sigExists, err = fileutil.FgrepStringInFile(origFile, nodeps.DdevFileSignature) 287 if err != nil { 288 return err 289 } 290 // If either of the files has #ddev-generated, we will respect both 291 if !sigExists { 292 break 293 } 294 } 295 } 296 // Assuming the certs don't exist, or they have #ddev-generated so can be replaced, create them 297 // But not if we don't have mkcert already set up. 298 if sigExists && globalconfig.DdevGlobalConfig.MkcertCARoot != "" { 299 c := []string{"--cert-file", baseName + ".crt", "--key-file", baseName + ".key", "*.ddev.site", "127.0.0.1", "localhost", "*.ddev.local", "ddev-router", "ddev-router.ddev", "ddev-router.ddev_default"} 300 c = append(c, hostnames...) 301 if app.ProjectTLD != nodeps.DdevDefaultTLD { 302 c = append(c, "*."+app.ProjectTLD) 303 } 304 out, err := exec.RunHostCommand("mkcert", c...) 305 if err != nil { 306 util.Failed("Failed to create certificates for project, check mkcert operation: %v; err=%v", out, err) 307 } 308 309 // Prepend #ddev-generated in generated crt and key files 310 for _, pemFile := range []string{app.Name + ".crt", app.Name + ".key"} { 311 origFile := filepath.Join(sourceCertsPath, pemFile) 312 313 contents, err := fileutil.ReadFileIntoString(origFile) 314 if err != nil { 315 return fmt.Errorf("failed to read file %v: %v", origFile, err) 316 } 317 contents = nodeps.DdevFileSignature + "\n" + contents 318 err = fileutil.TemplateStringToFile(contents, nil, origFile) 319 if err != nil { 320 return err 321 } 322 } 323 } 324 325 type traefikData struct { 326 App *DdevApp 327 Hostnames []string 328 PrimaryHostname string 329 TargetCertsPath string 330 RoutingTable []TraefikRouting 331 UseLetsEncrypt bool 332 } 333 templateData := traefikData{ 334 App: app, 335 Hostnames: []string{}, 336 PrimaryHostname: app.GetHostname(), 337 TargetCertsPath: targetCertsPath, 338 RoutingTable: routingTable, 339 UseLetsEncrypt: globalconfig.DdevGlobalConfig.UseLetsEncrypt, 340 } 341 342 // Convert externalHostnames wildcards like `*.<anything>` to `{subdomain:.+}.wild.ddev.site` 343 for i, v := range routingTable { 344 for j, h := range v.ExternalHostnames { 345 if strings.HasPrefix(h, `*.`) { 346 h = `{subdomain:.+}` + strings.TrimPrefix(h, `*`) 347 routingTable[i].ExternalHostnames[j] = h 348 } 349 } 350 } 351 352 traefikYamlFile := filepath.Join(sourceConfigDir, app.Name+".yaml") 353 sigExists = true 354 fi, err := os.Stat(traefikYamlFile) 355 // Don't use simple fileutil.FileExists() because of the danger of an empty file 356 if err == nil && fi.Size() > 0 { 357 // Check to see if file has #ddev-generated in it, meaning we can recreate it. 358 sigExists, err = fileutil.FgrepStringInFile(traefikYamlFile, nodeps.DdevFileSignature) 359 if err != nil { 360 return err 361 } 362 } 363 if !sigExists { 364 util.Debug("Not creating %s because it exists and is managed by user", traefikYamlFile) 365 } else { 366 f, err := os.Create(traefikYamlFile) 367 if err != nil { 368 return fmt.Errorf("failed to create Traefik config file: %v", err) 369 } 370 t, err := template.New("traefik_config_template.yaml").Funcs(getTemplateFuncMap()).ParseFS(bundledAssets, "traefik_config_template.yaml") 371 if err != nil { 372 return fmt.Errorf("could not create template from traefik_config_template.yaml: %v", err) 373 } 374 375 err = t.Execute(f, templateData) 376 if err != nil { 377 return fmt.Errorf("could not parse traefik_config_template.yaml with templatedate='%v':: %v", templateData, err) 378 } 379 } 380 381 uid, _, _ := util.GetContainerUIDGid() 382 383 err = dockerutil.CopyIntoVolume(projectTraefikDir, "ddev-global-cache", "traefik", uid, "", false) 384 if err != nil { 385 util.Warning("Failed to copy Traefik into Docker volume ddev-global-cache/traefik: %v", err) 386 } else { 387 util.Debug("Copied Traefik certs in %s to ddev-global-cache/traefik", sourceCertsPath) 388 } 389 if fileutil.FileExists(filepath.Join(customCertsPath, fmt.Sprintf("%s.crt", app.Name))) { 390 err = dockerutil.CopyIntoVolume(app.GetConfigPath("custom_certs"), "ddev-global-cache", "traefik/certs", uid, "", false) 391 if err != nil { 392 util.Warning("Failed copying custom certs into Docker volume ddev-global-cache/traefik/certs: %v", err) 393 } else { 394 util.Debug("Copied custom certs in %s to ddev-global-cache/traefik", sourceCertsPath) 395 } 396 } 397 return nil 398 }