diff --git a/README.md b/README.md index 8b01df8..f3ec081 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Supported frameworks and CMS: Bitrix, Laravel, WordPress, and many others with m - Portainer - docker container management system - Does not require root access (when installing the executable file in the user's directory) - Accessing sites from the browser via .localhost or .nip.io +- Mapping multiple domains to custom document roots via `HOSTS_MAP` - Ability to add custom docker-compose.yaml files to DL configuration ## Dependencies diff --git a/command/up.go b/command/up.go index 9cee823..4f38b81 100644 --- a/command/up.go +++ b/command/up.go @@ -133,4 +133,14 @@ func showProjectInfo() { } _ = pterm.DefaultPanel.WithPanels(panels).WithPadding(5).Render() + + mappings := project.HostMappings() + if len(mappings) > 0 { + table := pterm.TableData{{"Host", "Document root"}} + for _, mapping := range mappings { + table = append(table, []string{mapping.Host, mapping.DocumentRoot}) + } + pterm.Println() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + } } diff --git a/project/env.go b/project/env.go index c5e7646..5771090 100644 --- a/project/env.go +++ b/project/env.go @@ -54,8 +54,9 @@ func LoadEnv() { os.Exit(1) } - setDefaultEnv() - setComposeFiles() +setDefaultEnv() +setComposeFiles() +configureVirtualHosts() } // setNetworkName Set network name from project name @@ -93,7 +94,8 @@ func setDefaultEnv() { Env.SetDefault("REDIS", false) Env.SetDefault("REDIS_PASSWORD", "pass") - Env.SetDefault("MEMCACHED", false) +Env.SetDefault("MEMCACHED", false) +Env.SetDefault("TRAEFIK_EXTRA_RULE", "") host := getLocalIP() diff --git a/project/hosts.go b/project/hosts.go new file mode 100644 index 0000000..70f882c --- /dev/null +++ b/project/hosts.go @@ -0,0 +1,365 @@ +package project + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/local-deploy/dl/utils" + "github.com/pterm/pterm" + "github.com/sirupsen/logrus" +) + +// HostMapping describes the host to document root mapping declared in .env. +type HostMapping struct { + Host string + DocumentRoot string +} + +var hostMappings []HostMapping + +type virtualHost struct { + Primary string + Aliases []string + DocumentRoot string + IsBitrix bool +} + +type nginxTemplateData struct { + HostName string + Servers []virtualHost +} + +func configureVirtualHosts() { + hostMappings = parseHostMappings(Env.GetString("HOSTS_MAP")) + Env.Set("TRAEFIK_EXTRA_RULE", buildTraefikRule(hostMappings)) + + if err := ensureApacheConfig(); err != nil { + pterm.FgRed.Printfln("failed to prepare apache config: %s", err) + os.Exit(1) + } + + if len(hostMappings) == 0 { + return + } + + if Env.InConfig("NGINX_CONF") { + logrus.Warn("HOSTS_MAP is set but custom NGINX_CONF is used; skip autogenerated config") + return + } + + path, err := renderNginxConfig() + if err != nil { + pterm.FgRed.Printfln("failed to prepare nginx config: %s", err) + os.Exit(1) + } + + logrus.Infof("Using generated nginx config: %s", path) + Env.Set("NGINX_CONF", path) +} + +func ensureApacheConfig() error { + if Env.InConfig("APACHE_CONF") && Env.GetString("APACHE_CONF") != "" { + return nil + } + + path, err := renderApacheConfig() + if err != nil { + return err + } + + logrus.Infof("Using generated apache config: %s", path) + Env.Set("APACHE_CONF", path) + + return nil +} + +func renderNginxConfig() (string, error) { + tpl, err := template.New("nginx").Funcs(template.FuncMap{"join": strings.Join}).Parse(nginxTemplate) + if err != nil { + return "", err + } + + data := nginxTemplateData{ + HostName: Env.GetString("HOST_NAME"), + Servers: buildServerDefinitions(), + } + + var buf bytes.Buffer + if err = tpl.Execute(&buf, data); err != nil { + return "", err + } + + return writeProjectFile("nginx", "default.conf.template", buf.Bytes()) +} + +func renderApacheConfig() (string, error) { + tpl, err := template.New("apache").Funcs(template.FuncMap{"join": strings.Join}).Parse(apacheTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err = tpl.Execute(&buf, buildServerDefinitions()); err != nil { + return "", err + } + + return writeProjectFile("apache", "vhost.conf", buf.Bytes()) +} + +func writeProjectFile(component, filename string, content []byte) (string, error) { + dir := filepath.Join(Env.GetString("PWD"), ".dl", component) + if err := utils.CreateDirectory(dir); err != nil { + return "", err + } + + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, content, 0o644); err != nil { + return "", err + } + + return path, nil +} + +func buildServerDefinitions() []virtualHost { + baseRoot := strings.TrimSpace(Env.GetString("DOCUMENT_ROOT")) + base := virtualHost{DocumentRoot: baseRoot, IsBitrix: utils.BitrixCheck(baseRoot)} + + primaryCandidates := uniqueNonEmpty([]string{ + Env.GetString("LOCAL_DOMAIN"), + Env.GetString("NIP_DOMAIN"), + Env.GetString("HOST_NAME"), + }) + + for _, candidate := range primaryCandidates { + addName(&base, candidate) + } + + extra := []*virtualHost{} + index := map[string]int{} + + for _, mapping := range hostMappings { + host := strings.TrimSpace(mapping.Host) + root := strings.TrimSpace(mapping.DocumentRoot) + if host == "" || root == "" { + continue + } + + if root == base.DocumentRoot { + addName(&base, host) + continue + } + + position, ok := index[root] + if !ok { + index[root] = len(extra) + extra = append(extra, &virtualHost{DocumentRoot: root, IsBitrix: utils.BitrixCheck(root)}) + position = len(extra) - 1 + } + + addName(extra[position], host) + } + + hosts := []virtualHost{base} + for _, item := range extra { + if item.Primary == "" { + continue + } + hosts = append(hosts, *item) + } + + return hosts +} + +func addName(host *virtualHost, name string) { + if name == "" { + return + } + if host.Primary == "" { + host.Primary = name + return + } + if host.Primary == name { + return + } + for _, alias := range host.Aliases { + if alias == name { + return + } + } + host.Aliases = append(host.Aliases, name) +} + +func uniqueNonEmpty(values []string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} + +func parseHostMappings(raw string) []HostMapping { + entries := splitMappingEntries(raw) + seen := map[string]int{} + result := make([]HostMapping, 0, len(entries)) + + for _, entry := range entries { + trimmed := strings.TrimSpace(entry) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + pair := strings.Split(trimmed, "=>") + if len(pair) != 2 { + pair = strings.SplitN(trimmed, "=", 2) + } + if len(pair) != 2 { + logrus.Warnf("Invalid host mapping entry: %s", trimmed) + continue + } + + host := strings.Trim(strings.TrimSpace(pair[0]), "\"'") + root := strings.Trim(strings.TrimSpace(pair[1]), "\"'") + if host == "" || root == "" { + continue + } + + if idx, ok := seen[host]; ok { + result[idx].DocumentRoot = root + continue + } + + seen[host] = len(result) + result = append(result, HostMapping{Host: host, DocumentRoot: root}) + } + + return result +} + +func splitMappingEntries(raw string) []string { + return strings.FieldsFunc(raw, func(r rune) bool { + switch r { + case '\n', '\r', ',', ';': + return true + default: + return false + } + }) +} + +func buildTraefikRule(mappings []HostMapping) string { + var builder strings.Builder + seen := map[string]struct{}{} + + for _, mapping := range mappings { + host := strings.TrimSpace(mapping.Host) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + builder.WriteString(" || Host(`") + builder.WriteString(host) + builder.WriteString("`)") + } + + return builder.String() +} + +func HostMappings() []HostMapping { + result := make([]HostMapping, len(hostMappings)) + copy(result, hostMappings) + return result +} + +func AdditionalCertificateHosts() []string { + names := make([]string, 0, len(hostMappings)) + seen := map[string]struct{}{} + for _, mapping := range hostMappings { + host := strings.TrimSpace(mapping.Host) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + names = append(names, host) + } + return names +} + +const nginxTemplate = `{{range .Servers}} +server { + listen 80; + listen 443; + + server_name {{.Primary}}{{if .Aliases}} {{join .Aliases " "}}{{end}}; + add_header Strict-Transport-Security "max-age=31536000" always; + client_max_body_size 200M; + + charset utf-8; + + set $root_path {{.DocumentRoot}}; + root $root_path; + + location / { + root $root_path; + index index.php index.html; + {{if .IsBitrix}} + try_files $uri $uri/ /index.php?$args /bitrix/urlrewrite.php?$args /bitrix/routing_index.php?$args; + {{else}} + try_files $uri $uri/ /index.php?$args; + {{end}} + } + + location ~ \.php$ { + fastcgi_pass {{$.HostName}}_php:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include /etc/nginx/fastcgi_params; + } + + location ~* ^.+\.(jpg|jpeg|gif|png|svg|js|css|mp3|ogg|mpeg|avi|zip|gz|bz2|rar|swf|ico|7z|doc|docx|map|ogg|otf|pdf|tff|tif|txt|wav|webp|woff|woff2|xls|xlsx|xml)$ { + expires 365d; + try_files $uri $uri/ 404 = @fallback; + } + + location @fallback { + return 302 https://{{$.HostName}}/$uri; + } +} + +{{end}}` + +const apacheTemplate = `{{range .}} + + ServerName {{.Primary}} + {{if .Aliases}}ServerAlias {{join .Aliases " "}} + {{end}} + DocumentRoot {{.DocumentRoot}} + + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog /var/log/apache2/error.log + CustomLog /var/log/apache2/access.log combined + + +{{end}}` diff --git a/project/ssl.go b/project/ssl.go index 2c6687d..4f3fec0 100644 --- a/project/ssl.go +++ b/project/ssl.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/local-deploy/dl/utils" "github.com/local-deploy/dl/utils/cert" @@ -49,10 +50,14 @@ func CreateCert() { certDir := filepath.Join(utils.CertDir(), Env.GetString("NETWORK_NAME")) _ = utils.CreateDirectory(certDir) - err = c.MakeCert([]string{ + certHosts := []string{ Env.GetString("LOCAL_DOMAIN"), Env.GetString("NIP_DOMAIN"), - }, Env.GetString("NETWORK_NAME")) + } + certHosts = append(certHosts, AdditionalCertificateHosts()...) + certHosts = uniqueHosts(certHosts) + + err = c.MakeCert(certHosts, Env.GetString("NETWORK_NAME")) if err != nil { pterm.FgRed.Printfln("Error: %s", err) } @@ -78,3 +83,21 @@ func CreateCert() { pterm.FgRed.Printfln("failed to create config certificate file: %s", err) } } + +func uniqueHosts(values []string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + + return result +} diff --git a/templates/.env.example b/templates/.env.example index b0bd3cf..c9b58ba 100644 --- a/templates/.env.example +++ b/templates/.env.example @@ -6,6 +6,8 @@ SERVER=127.0.0.1 ## Local container config ## DOCUMENT_ROOT=/var/www/html +# HOSTS_MAP allows you to specify additional hosts and document roots (host=>path pairs separated by commas) +# HOSTS_MAP=en.site.ru=>/var/www/html/public-web,site.ru=>/var/www/html/public-web ## Avalible fpm versions: 7.3-fpm 7.4-fpm 8.0-fpm 8.1-fpm 8.2-fpm 8.3-fpm 8.4-fpm ## ## Avalible apache versions: 7.3-apache 7.4-apache 8.0-apache 8.1-apache 8.2-apache 8.3-apache 8.4-apache ## PHP_VERSION=8.4-fpm diff --git a/templates/.env.example-bitrix b/templates/.env.example-bitrix index 3b94694..087bdeb 100644 --- a/templates/.env.example-bitrix +++ b/templates/.env.example-bitrix @@ -6,6 +6,8 @@ SERVER=127.0.0.1 ## Local container config ## DOCUMENT_ROOT=/var/www/html +# HOSTS_MAP allows you to specify additional hosts and document roots (host=>path pairs separated by commas) +# HOSTS_MAP=en.site.ru=>/var/www/html/public-web,site.ru=>/var/www/html/public-web ## Avalible fpm versions: 7.3-fpm 7.4-fpm 8.0-fpm 8.1-fpm 8.2-fpm 8.3-fpm 8.4-fpm ## ## Avalible apache versions: 7.3-apache 7.4-apache 8.0-apache 8.1-apache 8.2-apache 8.3-apache 8.4-apache ## PHP_VERSION=8.4-fpm diff --git a/templates/docker-compose-apache.yaml b/templates/docker-compose-apache.yaml index 3f414b6..6e6a031 100644 --- a/templates/docker-compose-apache.yaml +++ b/templates/docker-compose-apache.yaml @@ -23,13 +23,14 @@ services: - "${PHP_INI_SOURCE:-/dev/null}:/usr/local/etc/php/conf.custom.d/custom.ini:ro" - "~/.ssh/${SSH_KEY:-id_rsa}:/var/www/.ssh/id_rsa:ro" - "~/.ssh/known_hosts:/var/www/.ssh/known_hosts" + - "${APACHE_CONF}:/etc/apache2/sites-enabled/000-default.conf:ro" labels: - "traefik.enable=true" - "traefik.http.routers.${NETWORK_NAME}.entrypoints=web" - - "traefik.http.routers.${NETWORK_NAME}.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)" + - "traefik.http.routers.${NETWORK_NAME}.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)${TRAEFIK_EXTRA_RULE}" - "traefik.http.routers.${NETWORK_NAME}.middlewares=site-compress" - "traefik.http.routers.${NETWORK_NAME}_ssl.entrypoints=websecure" - - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)" + - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)${TRAEFIK_EXTRA_RULE}" - "traefik.http.routers.${NETWORK_NAME}_ssl.middlewares=site-compress" - "traefik.http.routers.${NETWORK_NAME}_ssl.tls=true" - "traefik.docker.network=dl_default" diff --git a/templates/docker-compose-fpm.yaml b/templates/docker-compose-fpm.yaml index 2e02f84..af510cc 100644 --- a/templates/docker-compose-fpm.yaml +++ b/templates/docker-compose-fpm.yaml @@ -36,9 +36,9 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.${NETWORK_NAME}.entrypoints=web" - - "traefik.http.routers.${NETWORK_NAME}.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)" + - "traefik.http.routers.${NETWORK_NAME}.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)${TRAEFIK_EXTRA_RULE}" - "traefik.http.routers.${NETWORK_NAME}_ssl.entrypoints=websecure" - - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)" + - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.localhost`) || HostRegexp(`${HOST_NAME}.{ip:.*}.nip.io`) || HostRegexp(`{subdomain:.*}.${HOST_NAME}.{ip:.*}.nip.io`)${TRAEFIK_EXTRA_RULE}" - "traefik.http.routers.${NETWORK_NAME}_ssl.tls=true" - "traefik.docker.network=dl_default" environment: