Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions command/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ func upRun() {
project.CreateCert()
}

// Generate web server config
phpVersion := project.Env.GetString("PHP_VERSION")
if strings.Contains(phpVersion, "apache") {
confPath, err := project.WriteApacheConfig()
if err != nil {
pterm.FgRed.Printfln("Failed to generate apache config: %s", err)
return
}
project.Env.Set("APACHE_CONF", confPath)
} else if strings.Contains(phpVersion, "fpm") && (len(project.Env.GetString("DOMAIN_MAP")) > 0 || len(project.Env.GetString("DOMAINS")) > 0) {
confPath, err := project.WriteNginxConfig()
if err != nil {
pterm.FgRed.Printfln("Failed to generate nginx config: %s", err)
return
}
project.Env.Set("NGINX_CONF", confPath)
}

bin, option := utils.GetCompose()
Args := []string{bin}
preArgs := []string{"-p", project.Env.GetString("NETWORK_NAME"), "--project-directory", project.Env.GetString("PWD"), "up", "-d"}
Expand Down Expand Up @@ -117,20 +135,26 @@ func startLocalServices() error {

// showProjectInfo Display project links
func showProjectInfo() {
l := project.Env.GetString("LOCAL_DOMAIN")
n := project.Env.GetString("NIP_DOMAIN")

schema := "http"

if viper.GetBool("ca") {
schema = "https"
}

pterm.FgCyan.Println()
panels := pterm.Panels{
{{Data: pterm.FgYellow.Sprintf("nip.io\nlocal")},
{Data: pterm.FgYellow.Sprintf(schema+"://%s/\n"+schema+"://%s/", n, l)}},
}

_ = pterm.DefaultPanel.WithPanels(panels).WithPadding(5).Render()
if len(project.Env.GetString("DOMAIN_MAP")) > 0 || len(project.Env.GetString("DOMAINS")) > 0 {
tableData := pterm.TableData{{"Domain", "Document Root"}}
for _, m := range project.DomainMappings {
tableData = append(tableData, []string{schema + "://" + m.LocalDomain + "/", m.DocumentRoot})
}
_ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
} else {
l := project.Env.GetString("LOCAL_DOMAIN")
n := project.Env.GetString("NIP_DOMAIN")
panels := pterm.Panels{
{{Data: pterm.FgYellow.Sprintf("nip.io\nlocal")},
{Data: pterm.FgYellow.Sprintf(schema+"://%s/\n"+schema+"://%s/", n, l)}},
}
_ = pterm.DefaultPanel.WithPanels(panels).WithPadding(5).Render()
}
}
19 changes: 19 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import (

var version = "dev"

// templateVersion must be incremented whenever files in the templates/ directory change.
// This triggers a full re-extraction of embedded templates to ~/.config/dl/templates/.
const templateVersion = "2"

//go:embed templates/*
var templates embed.FS

Expand Down Expand Up @@ -79,6 +83,21 @@ func initConfig() {
}
}

// Re-extract templates when templateVersion changes
if viper.GetString("template_version") != templateVersion {
err = utils.CreateTemplates(true)
if err != nil {
pterm.FgRed.Printfln("Error updating templates: %s \n", err)
os.Exit(1)
}
viper.Set("template_version", templateVersion)
err = viper.WriteConfig()
if err != nil {
pterm.FgRed.Printfln("Error config file: %s \n", err)
os.Exit(1)
}
}

viper.AutomaticEnv()
}

Expand Down
62 changes: 62 additions & 0 deletions project/apache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package project

import (
"fmt"
"os"
"path/filepath"

"github.com/sirupsen/logrus"
)

// GenerateApacheConfig builds Apache vhost configuration from DomainMappings
func GenerateApacheConfig() string {
var b []byte

for i, dm := range DomainMappings {
if i > 0 {
b = append(b, '\n')
}
b = fmt.Appendf(b, "<VirtualHost *:80>\n")
b = fmt.Appendf(b, " ServerName %s\n", dm.LocalDomain)
b = fmt.Appendf(b, " ServerAlias %s\n", dm.NipDomain)
b = fmt.Appendf(b, " DocumentRoot %s\n", dm.DocumentRoot)
b = fmt.Appendf(b, "\n")
b = fmt.Appendf(b, " <Directory %s>\n", dm.DocumentRoot)
b = fmt.Appendf(b, " AllowOverride All\n")
b = fmt.Appendf(b, " Require all granted\n")
b = fmt.Appendf(b, " </Directory>\n")
b = fmt.Appendf(b, "</VirtualHost>\n")
}

return string(b)
}

// WriteApacheConfig writes the generated Apache vhost config to /tmp/dl/<project>/apache/.
// Skips regeneration if .env and project folder haven't changed.
// Returns the absolute path to the config file.
func WriteApacheConfig() (string, error) {
networkName := Env.GetString("NETWORK_NAME")
dir := filepath.Join(os.TempDir(), "dl", networkName, "apache")
confPath := filepath.Join(dir, "vhosts.conf")
hashPath := filepath.Join(dir, ".confhash")

if !configNeedsUpdate(hashPath) {
logrus.Info("Apache config is up to date, skipping regeneration")
return confPath, nil
}

if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("failed to create directory %s: %w", dir, err)
}

config := GenerateApacheConfig()

if err := os.WriteFile(confPath, []byte(config), 0644); err != nil {
return "", fmt.Errorf("failed to write %s: %w", confPath, err)
}

saveConfigHash(hashPath)
logrus.Info("Apache config regenerated")

return confPath, nil
}
14 changes: 9 additions & 5 deletions project/deploy_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,18 @@ func (c SSHClient) ImportDB(ctx context.Context) error {
}

if c.Config.FwType == "bitrix" {
local := Env.GetString("LOCAL_DOMAIN")
nip := Env.GetString("NIP_DOMAIN")
firstDomain := DomainMappings[0]

var domainInserts strings.Builder
for _, m := range DomainMappings {
domainInserts.WriteString(fmt.Sprintf("INSERT IGNORE INTO b_lang_domain VALUES ('s1', '%s');\n", m.LocalDomain))
domainInserts.WriteString(fmt.Sprintf("INSERT IGNORE INTO b_lang_domain VALUES ('s1', '%s');\n", m.NipDomain))
}

strSQL := `"UPDATE b_option SET VALUE = 'Y' WHERE MODULE_ID = 'main' AND NAME = 'update_devsrv';
UPDATE b_lang SET SERVER_NAME='` + site + `.localhost' WHERE LID='s1';
UPDATE b_lang SET SERVER_NAME='` + firstDomain.LocalDomain + `' WHERE LID='s1';
UPDATE b_lang SET b_lang.DOC_ROOT='' WHERE 1=(SELECT DOC_ROOT FROM (SELECT COUNT(LID) FROM b_lang) as cnt);
INSERT IGNORE INTO b_lang_domain VALUES ('s1', '` + local + `');
INSERT IGNORE INTO b_lang_domain VALUES ('s1', '` + nip + `');"`
` + domainInserts.String() + `"`

commandUpdate := "echo " + strSQL + " | " + docker + " exec -i " + siteDB + " /usr/bin/mysql --user=" + mysqlUser + " --password=" + mysqlPassword + " --host=db " + mysqlDB + ""
logrus.Infof("Run command: %s", commandUpdate)
Expand Down
168 changes: 168 additions & 0 deletions project/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import (
// Env Project variables
var Env *viper.Viper

// DomainMappings holds parsed domain-to-document-root mappings
var DomainMappings []DomainMapping

var phpImagesVersion = map[string]string{
"7.3-apache": "1.1.3",
"7.3-fpm": "1.0.3",
Expand Down Expand Up @@ -55,6 +58,13 @@ func LoadEnv() {
}

setDefaultEnv()

if err := validateDomains(); err != nil {
pterm.FgRed.Println(err)
os.Exit(1)
}
parseDomainMappings()

setComposeFiles()
}

Expand Down Expand Up @@ -91,6 +101,8 @@ func setDefaultEnv() {
Env.Set("NGINX_CONF", getNginxConf())
}

Env.SetDefault("APACHE_CONF", "/dev/null")

Env.SetDefault("REDIS", false)
Env.SetDefault("REDIS_PASSWORD", "pass")
Env.SetDefault("MEMCACHED", false)
Expand Down Expand Up @@ -218,6 +230,162 @@ func IsEnvExampleFileExists() bool {
return err == nil
}

func validateDomains() error {
domains := Env.GetString("DOMAINS")
domainMap := Env.GetString("DOMAIN_MAP")

// Rule 1: DOMAINS and DOMAIN_MAP are mutually exclusive
if len(domains) > 0 && len(domainMap) > 0 {
return fmt.Errorf("DOMAINS and DOMAIN_MAP are mutually exclusive. Use DOMAINS for multiple domains with one document root, or DOMAIN_MAP for different document roots per domain.")
}

domainNameRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]*$`)
seen := make(map[string]bool)

if len(domainMap) > 0 {
entries := strings.Split(domainMap, ",")
for _, entry := range entries {
entry = strings.TrimSpace(entry)

// Rule 3: must contain ':'
if !strings.Contains(entry, ":") {
return fmt.Errorf("Invalid DOMAIN_MAP format for entry \"%s\". Expected format: name:/path/to/docroot", entry)
}

parts := strings.SplitN(entry, ":", 2)
name := strings.TrimSpace(parts[0])
path := strings.TrimSpace(parts[1])

// Rule 4: empty name
if len(name) == 0 {
return fmt.Errorf("Empty domain name in DOMAIN_MAP entry: \"%s\"", entry)
}

// Rule 5: empty path
if len(path) == 0 {
return fmt.Errorf("Empty document root in DOMAIN_MAP entry for domain \"%s\"", name)
}

// Rule 6: path must be absolute
if !filepath.IsAbs(path) {
return fmt.Errorf("Document root must be an absolute path for domain \"%s\": got \"%s\"", name, path)
}

nameLower := strings.ToLower(name)

// Rule 8: invalid domain name chars
if !domainNameRegex.MatchString(nameLower) {
return fmt.Errorf("Invalid domain name \"%s\": only alphanumeric characters, hyphens, and dots are allowed.", nameLower)
}

// Rule 9: duplicate domain
if seen[nameLower] {
return fmt.Errorf("Duplicate domain name \"%s\" in DOMAIN_MAP.", nameLower)
}
seen[nameLower] = true
}
}

if len(domains) > 0 {
// Rule 2: DOMAINS requires DOCUMENT_ROOT
if len(Env.GetString("DOCUMENT_ROOT")) == 0 {
return fmt.Errorf("DOCUMENT_ROOT is required when using DOMAINS.")
}

entries := strings.Split(domains, ",")
for _, entry := range entries {
name := strings.TrimSpace(entry)

// Rule 7: empty entry
if len(name) == 0 {
return fmt.Errorf("Empty domain name in DOMAINS. Check for trailing commas.")
}

nameLower := strings.ToLower(name)

// Rule 8: invalid domain name chars
if !domainNameRegex.MatchString(nameLower) {
return fmt.Errorf("Invalid domain name \"%s\": only alphanumeric characters, hyphens, and dots are allowed.", nameLower)
}

// Rule 9: duplicate domain
if seen[nameLower] {
return fmt.Errorf("Duplicate domain name \"%s\" in DOMAINS.", nameLower)
}
seen[nameLower] = true
}
}

return nil
}

func parseDomainMappings() {
DomainMappings = nil
localIP := Env.GetString("LOCAL_IP")
domainMap := Env.GetString("DOMAIN_MAP")
domains := Env.GetString("DOMAINS")

if len(domainMap) > 0 {
entries := strings.Split(domainMap, ",")
for _, entry := range entries {
entry = strings.TrimSpace(entry)
parts := strings.SplitN(entry, ":", 2)
name := strings.ToLower(strings.TrimSpace(parts[0]))
docRoot := strings.TrimSpace(parts[1])

DomainMappings = append(DomainMappings, DomainMapping{
Name: name,
DocumentRoot: docRoot,
LocalDomain: fmt.Sprintf("%s.localhost", name),
NipDomain: fmt.Sprintf("%s.%s.nip.io", name, localIP),
})
}
logrus.Infof("Parsed %d domain mappings from DOMAIN_MAP", len(DomainMappings))
generateTraefikRule()
return
}

if len(domains) > 0 {
docRoot := Env.GetString("DOCUMENT_ROOT")
entries := strings.Split(domains, ",")
for _, entry := range entries {
name := strings.ToLower(strings.TrimSpace(entry))

DomainMappings = append(DomainMappings, DomainMapping{
Name: name,
DocumentRoot: docRoot,
LocalDomain: fmt.Sprintf("%s.localhost", name),
NipDomain: fmt.Sprintf("%s.%s.nip.io", name, localIP),
})
}
logrus.Infof("Parsed %d domain mappings from DOMAINS", len(DomainMappings))
generateTraefikRule()
return
}

// Backward compatibility: single mapping from HOST_NAME + DOCUMENT_ROOT
hostName := strings.ToLower(Env.GetString("HOST_NAME"))
DomainMappings = append(DomainMappings, DomainMapping{
Name: hostName,
DocumentRoot: Env.GetString("DOCUMENT_ROOT"),
LocalDomain: fmt.Sprintf("%s.localhost", hostName),
NipDomain: fmt.Sprintf("%s.%s.nip.io", hostName, localIP),
})
logrus.Infof("Using single domain mapping from HOST_NAME: %s", hostName)
generateTraefikRule()
}

func generateTraefikRule() {
var rules []string
for _, m := range DomainMappings {
rules = append(rules, fmt.Sprintf("Host(`%s`)", m.LocalDomain))
rules = append(rules, fmt.Sprintf("HostRegexp(`^.+\\.%s$`)", m.LocalDomain))
rules = append(rules, fmt.Sprintf("HostRegexp(`^%s\\..+\\.nip\\.io$`)", m.Name))
rules = append(rules, fmt.Sprintf("HostRegexp(`^.+\\.%s\\..+\\.nip\\.io$`)", m.Name))
}
Env.Set("TRAEFIK_RULE", strings.Join(rules, " || "))
}

func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
Expand Down
Loading