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  }