From f0073a649f909992cd4c8aea239c755dbe7ec48c Mon Sep 17 00:00:00 2001 From: Tom Wiesing Date: Wed, 8 Mar 2023 11:27:19 +0100 Subject: [PATCH] Multiplex http and ssh ports --- .tool-versions | 2 +- internal/config/config.go | 4 +- internal/config/config.yml | 9 ++ internal/config/http.go | 8 ++ internal/config/legacy/legacy.go | 6 +- internal/config/ports.go | 33 +++++++ internal/config/template.go | 6 +- internal/config/validators/collection.go | 1 + internal/dis/component/auth/panel/ssh.go | 2 +- internal/dis/component/binder/binder.env | 1 + internal/dis/component/binder/binder.go | 88 +++++++++++++++++++ .../dis/component/binder/docker-compose.yml | 17 ++++ internal/dis/component/ssh2/server_handler.go | 2 +- internal/dis/component/ssh2/ssh2.env | 2 - .../component/ssh2/ssh2/docker-compose.yml | 5 +- internal/dis/component/ssh2/stack.go | 7 +- internal/dis/component/stack.go | 4 + .../dis/component/web/docker-compose-http.yml | 8 +- .../component/web/docker-compose-https.yml | 10 +-- internal/dis/distillery.go | 2 + 20 files changed, 188 insertions(+), 29 deletions(-) create mode 100644 internal/config/ports.go create mode 100644 internal/dis/component/binder/binder.env create mode 100644 internal/dis/component/binder/binder.go create mode 100644 internal/dis/component/binder/docker-compose.yml diff --git a/.tool-versions b/.tool-versions index 9c52ce3..414a460 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.20.1 \ No newline at end of file +golang 1.20.2 \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 99cabe4..a67f693 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ import ( // Config contains many methods that do not require any interaction with any running components. // Methods that require running components are instead store inside the [Distillery] or an appropriate [Component]. type Config struct { + Listen ListenConfig `yaml:"listen" recurse:"true"` Paths PathsConfig `yaml:"paths" recurse:"true"` HTTP HTTPConfig `yaml:"http" recurse:"true"` Theme ThemeConfig `yaml:"theme" recurse:"true"` @@ -37,9 +38,6 @@ type Config struct { // This variable can be used to determine their length. PasswordLength int `yaml:"password_length" default:"64" validate:"positive"` - // Public port to use for the ssh server - PublicSSHPort uint16 `yaml:"ssh_port" default:"2222" validate:"port"` - // session secret holds the secret for login SessionSecret string `yaml:"session_secret" validate:"nonempty" sensitive:"true"` diff --git a/internal/config/config.yml b/internal/config/config.yml index 8c656cc..cd1b360 100644 --- a/internal/config/config.yml +++ b/internal/config/config.yml @@ -1,3 +1,12 @@ +listen: + # A list of ports the distillery should accept traffic on. + # Each of these ports accepts http, https and ssh traffic via a multiplexer. + ports: null + + # The ssh port that is shown to the user in various interfaces. + # This port is not automatically included in the ports to listen to. + advertise_ssh: null + paths: # A WissKI Distillery needs to store a lot of data on disk. # This setting defines a root folder all of these will be placed in. diff --git a/internal/config/http.go b/internal/config/http.go index 69ccd67..bdb4876 100644 --- a/internal/config/http.go +++ b/internal/config/http.go @@ -23,6 +23,14 @@ type HTTPConfig struct { CertbotEmail string `yaml:"certbot_email" validate:"email"` } +// TCPMuxCommand generates a command line for the sslh executable. +func (hcfg HTTPConfig) TCPMuxCommand(addr string, http string, https string, ssh string) string { + if hcfg.HTTPSEnabled() { + return fmt.Sprintf("-bind %s -http %s -tls %s -rest %s", addr, http, https, ssh) + } + return fmt.Sprintf("-bind %s -http %s -rest %s", addr, http, ssh) +} + // HTTPSEnabled returns if the distillery has HTTPS enabled, and false otherwise. func (hcfg HTTPConfig) HTTPSEnabled() bool { return hcfg.CertbotEmail != "" diff --git a/internal/config/legacy/legacy.go b/internal/config/legacy/legacy.go index 138c4c7..8ebd165 100644 --- a/internal/config/legacy/legacy.go +++ b/internal/config/legacy/legacy.go @@ -84,7 +84,11 @@ func (legacy *Legacy) Migrate(cfg *config.Config) error { cfg.TS.DataPrefix = legacy.GraphDBRepoPrefix cfg.SQL.Database = legacy.DistilleryDatabase cfg.PasswordLength = legacy.PasswordLength - cfg.PublicSSHPort = legacy.PublicSSHPort + cfg.Listen.Ports = []uint16{80, legacy.PublicSSHPort} + if legacy.CertbotEmail != "" { + cfg.Listen.Ports = append(cfg.Listen.Ports, 443) + } + cfg.Listen.AdvertisedSSHPort = legacy.PublicSSHPort cfg.TS.AdminUsername = legacy.TriplestoreAdminUser cfg.TS.AdminPassword = legacy.TriplestoreAdminPassword cfg.SQL.AdminUsername = legacy.MysqlAdminUser diff --git a/internal/config/ports.go b/internal/config/ports.go new file mode 100644 index 0000000..c08f7ce --- /dev/null +++ b/internal/config/ports.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + + "golang.org/x/exp/slices" +) + +type ListenConfig struct { + // Ports are the public addresses to bind to. + // Each address is automatically multiplexed to serve http, https and ssh traffic. + // This should typically be port 80 and port 443. + Ports []uint16 `yaml:"ports" default:"80" validate:"ports"` + + // AdvertisedSSHPort is the port that shows up as the ssh port in various places in the interface. + // It is automaticalled added to the ports to listen to. + AdvertisedSSHPort uint16 `yaml:"advertise_ssh" default:"80" validate:"port"` +} + +// ComposePorts returns a list of ports to be used within a docker-compose.yml file. +// These can be used to forward all ports to the internal port. +func (lc ListenConfig) ComposePorts(internal string) []string { + // sort and uniquify ports + ports := append([]uint16{lc.AdvertisedSSHPort}, lc.Ports...) + slices.Sort(ports) + ports = slices.Compact(ports) + + forwards := make([]string, len(ports)) + for i, port := range ports { + forwards[i] = fmt.Sprintf("%d:%s", port, internal) + } + return forwards +} diff --git a/internal/config/template.go b/internal/config/template.go index 25d7ff4..ade9638 100644 --- a/internal/config/template.go +++ b/internal/config/template.go @@ -79,6 +79,10 @@ func (tpl *Template) SetDefaults() (err error) { // Generate generates a configuration file for this configuration func (tpl Template) Generate() Config { return Config{ + Listen: ListenConfig{ + Ports: []uint16{80}, + AdvertisedSSHPort: 80, + }, Paths: PathsConfig{ Root: tpl.RootPath, OverridesJSON: filepath.Join(tpl.RootPath, bootstrap.OverridesJSON), @@ -114,8 +118,6 @@ func (tpl Template) Generate() Config { MaxBackupAge: 30 * 24 * time.Hour, // 1 month PasswordLength: 64, - PublicSSHPort: 2222, - SessionSecret: tpl.SessionSecret, CronInterval: 10 * time.Minute, } diff --git a/internal/config/validators/collection.go b/internal/config/validators/collection.go index 5e4022a..e38973f 100644 --- a/internal/config/validators/collection.go +++ b/internal/config/validators/collection.go @@ -21,6 +21,7 @@ func New() validator.Collection { validator.Add(coll, "positive", ValidatePositive) validator.Add(coll, "port", ValidatePort) + validator.AddSlice(coll, "ports", ",", ValidatePort) validator.Add(coll, "duration", ValidateDuration) return coll diff --git a/internal/dis/component/auth/panel/ssh.go b/internal/dis/component/auth/panel/ssh.go index d7c27fc..034565e 100644 --- a/internal/dis/component/auth/panel/ssh.go +++ b/internal/dis/component/auth/panel/ssh.go @@ -59,7 +59,7 @@ func (panel *UserPanel) sshRoute(ctx context.Context) http.Handler { } sc.Domain = panel.Config.HTTP.PrimaryDomain - sc.Port = panel.Config.PublicSSHPort + sc.Port = panel.Config.Listen.AdvertisedSSHPort // pick the first domain that the user has access to as an example grants, err := panel.Dependencies.Policy.User(r.Context(), user.User.User) diff --git a/internal/dis/component/binder/binder.env b/internal/dis/component/binder/binder.env new file mode 100644 index 0000000..533fa65 --- /dev/null +++ b/internal/dis/component/binder/binder.env @@ -0,0 +1 @@ +DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME} \ No newline at end of file diff --git a/internal/dis/component/binder/binder.go b/internal/dis/component/binder/binder.go new file mode 100644 index 0000000..0c308dd --- /dev/null +++ b/internal/dis/component/binder/binder.go @@ -0,0 +1,88 @@ +package binder + +import ( + "bytes" + "embed" + "io" + "path/filepath" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/tkw1536/pkglib/yamlx" + "gopkg.in/yaml.v3" +) + +type Binder struct { + component.Base +} + +var ( + _ component.Installable = (*Binder)(nil) +) + +func (binder *Binder) Path() string { + return filepath.Join(binder.Still.Config.Paths.Root, "core", "binder") +} + +func (binder *Binder) Context(parent component.InstallationContext) component.InstallationContext { + return parent +} + +//go:embed docker-compose.yml +var composeTemplate []byte + +func (binder *Binder) buildYML() ([]byte, error) { + var dockerCompose yaml.Node + if err := yaml.Unmarshal(composeTemplate, &dockerCompose); err != nil { + return nil, err + } + + for dockerCompose.Kind == yaml.DocumentNode { + dockerCompose = *dockerCompose.Content[0] + } + + { + ports := binder.Config.Listen.ComposePorts("8000") + portsNode, err := yamlx.Marshal(ports) + if err != nil { + return nil, err + } + if err := yamlx.Replace(&dockerCompose, *portsNode, "services", "binder", "ports"); err != nil { + return nil, err + } + } + + { + command := binder.Config.HTTP.TCPMuxCommand("0.0.0.0:8000", "http:80", "http:443", "ssh:2222") + commandNode, err := yamlx.Marshal(command) + if err != nil { + return nil, err + } + if err := yamlx.Replace(&dockerCompose, *commandNode, "services", "binder", "command"); err != nil { + return nil, err + } + } + + // do the final marshal + return yaml.Marshal(dockerCompose) +} + +//go:embed binder.env +var resources embed.FS + +func (binder *Binder) Stack() component.StackWithResources { + return component.MakeStack(binder, component.StackWithResources{ + Resources: resources, + EnvPath: "binder.env", + ReadComposeFile: func() (io.Reader, error) { + data, err := binder.buildYML() + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil + }, + + EnvContext: map[string]string{ + "DOCKER_NETWORK_NAME": binder.Config.Docker.Network, + }, + }) +} diff --git a/internal/dis/component/binder/docker-compose.yml b/internal/dis/component/binder/docker-compose.yml new file mode 100644 index 0000000..7735854 --- /dev/null +++ b/internal/dis/component/binder/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.7" + +services: + binder: + image: ghcr.io/fau-cdi/tcpmux + # dynamically generated + command: [] + # dynamically generated + ports: [] + restart: always + networks: + - default + +networks: + default: + name: ${DOCKER_NETWORK_NAME} + external: true diff --git a/internal/dis/component/ssh2/server_handler.go b/internal/dis/component/ssh2/server_handler.go index 5c00a5f..7e52804 100644 --- a/internal/dis/component/ssh2/server_handler.go +++ b/internal/dis/component/ssh2/server_handler.go @@ -82,7 +82,7 @@ func (ssh2 *SSH2) handleConnection(session ssh.Session) { {"${SLUG}", slug}, {"${DOMAIN}", ssh2.Config.HTTP.PrimaryDomain}, {"${HOSTNAME}", slug + "." + ssh2.Config.HTTP.PrimaryDomain}, - {"${PORT}", strconv.FormatUint(uint64(ssh2.Config.PublicSSHPort), 10)}, + {"${PORT}", strconv.FormatUint(uint64(ssh2.Config.Listen.AdvertisedSSHPort), 10)}, } { banner = strings.ReplaceAll(banner, oldnew[0], oldnew[1]) } diff --git a/internal/dis/component/ssh2/ssh2.env b/internal/dis/component/ssh2/ssh2.env index 886eb36..64c7d31 100644 --- a/internal/dis/component/ssh2/ssh2.env +++ b/internal/dis/component/ssh2/ssh2.env @@ -6,5 +6,3 @@ SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE} SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE} DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME} -HTTPS_ENABLED=${HTTPS_ENABLED} -SSH_PORT=${SSH_PORT} \ No newline at end of file diff --git a/internal/dis/component/ssh2/ssh2/docker-compose.yml b/internal/dis/component/ssh2/ssh2/docker-compose.yml index 5686e68..73c8034 100644 --- a/internal/dis/component/ssh2/ssh2/docker-compose.yml +++ b/internal/dis/component/ssh2/ssh2/docker-compose.yml @@ -1,13 +1,12 @@ version: "3.7" services: - dis: + ssh: + read_only: true build: . restart: always environment: CONFIG_PATH: ${CONFIG_PATH} - ports: - - "${SSH_PORT}:2222" volumes: - "/var/run/docker.sock:/var/run/docker.sock" - "${CONFIG_PATH}:${CONFIG_PATH}:ro" diff --git a/internal/dis/component/ssh2/stack.go b/internal/dis/component/ssh2/stack.go index f87d23f..9a4e1df 100644 --- a/internal/dis/component/ssh2/stack.go +++ b/internal/dis/component/ssh2/stack.go @@ -3,7 +3,6 @@ package ssh2 import ( "embed" "path/filepath" - "strconv" "github.com/FAU-CDI/wisski-distillery/internal/bootstrap" "github.com/FAU-CDI/wisski-distillery/internal/dis/component" @@ -17,7 +16,7 @@ func (ssh SSH2) Path() string { var resources embed.FS func (ssh *SSH2) Stack() component.StackWithResources { - stt := component.MakeStack(ssh, component.StackWithResources{ + return component.MakeStack(ssh, component.StackWithResources{ Resources: resources, ContextPath: "ssh2", EnvPath: "ssh2.env", @@ -25,20 +24,16 @@ func (ssh *SSH2) Stack() component.StackWithResources { EnvContext: map[string]string{ "DOCKER_NETWORK_NAME": ssh.Config.Docker.Network, "HOST_RULE": ssh.Config.HTTP.DefaultHostRule(), - "HTTPS_ENABLED": ssh.Config.HTTP.HTTPSEnabledEnv(), "CONFIG_PATH": ssh.Config.ConfigPath, "DEPLOY_ROOT": ssh.Config.Paths.Root, "SELF_OVERRIDES_FILE": ssh.Config.Paths.OverridesJSON, "SELF_RESOLVER_BLOCK_FILE": ssh.Config.Paths.ResolverBlocks, - - "SSH_PORT": strconv.FormatUint(uint64(ssh.Config.PublicSSHPort), 10), }, CopyContextFiles: []string{bootstrap.Executable}, }) - return stt } func (ssh SSH2) Context(parent component.InstallationContext) component.InstallationContext { diff --git a/internal/dis/component/stack.go b/internal/dis/component/stack.go index 8ad0ac5..cc56d80 100644 --- a/internal/dis/component/stack.go +++ b/internal/dis/component/stack.go @@ -192,6 +192,10 @@ func (is StackWithResources) Install(ctx context.Context, progress io.Writer, co ); err != nil { return err } + } else { + if err := fsx.MkdirAll(is.Dir, fsx.DefaultDirPerm); err != nil { + return err + } } // write the docker-compose.yml file diff --git a/internal/dis/component/web/docker-compose-http.yml b/internal/dis/component/web/docker-compose-http.yml index 774f692..42e709e 100644 --- a/internal/dis/component/web/docker-compose-http.yml +++ b/internal/dis/component/web/docker-compose-http.yml @@ -1,7 +1,7 @@ version: "3.7" services: - reverse-proxy: + http: image: docker.io/library/traefik:v2.9 command: - "--providers.docker" @@ -12,9 +12,9 @@ services: ## for debugging purposes, the following can be enabled. # - "--api.insecure=true" - ports: - - "80:80" - # - "127.0.0.1:8888:8080" + #ports: + # # - "80:80" + # # - "127.0.0.1:8888:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock" restart: always diff --git a/internal/dis/component/web/docker-compose-https.yml b/internal/dis/component/web/docker-compose-https.yml index 7965e05..755e473 100644 --- a/internal/dis/component/web/docker-compose-https.yml +++ b/internal/dis/component/web/docker-compose-https.yml @@ -1,7 +1,7 @@ version: "3.7" services: - reverse-proxy: + http: image: docker.io/library/traefik:v2.9 command: - "--providers.docker" @@ -24,10 +24,10 @@ services: # - "--api.insecure=true" # - "--certificatesresolvers.distillery.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - ports: - - "80:80" - - "443:443" - # - "127.0.0.1:8888:8080" + #ports: + # # - "80:80" + # # - "443:443" + # # - "127.0.0.1:8888:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock" - "./acme.json:/acme.json" diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index 2fcc2b5..de85e34 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -11,6 +11,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/next" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/panel" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/binder" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/docker" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger" @@ -131,6 +132,7 @@ func (dis *Distillery) Purger() *purger.Purger { func (dis *Distillery) allComponents() []initFunc { return []initFunc{ auto[*docker.Docker], + auto[*binder.Binder], auto[*web.Web], manual(func(ts *triplestore.Triplestore) {