diff --git a/README.md b/README.md index 85a57df..4141087 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ No technical reasons using `sudo` or switching to `root` is not possible. ### Authentication Authentication is performed using SSH Keys. +They are associated o distillery user accounts. Within each instance, ssh keys can be added to the file `/var/www/.ssh/authorized_keys` using the default OpenSSH `authorized_keys` format. Furthermore, global ssh Keys (that have access to every instance) can be added to a `GLOBAL_AUTHORIZED_KEYS_FILE`. This is set in the Distillery `.env` file, and defaults to `/distillery/authorized_keys/`. diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index f8fe085..8670121 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -148,16 +148,6 @@ func (bs cBootstrap) Run(context wisski_distillery.Context) error { return err } - context.Println(tpl.AuthorizedKeys) - if err := environment.WriteFile( - env, - tpl.AuthorizedKeys, - bootstrap.DefaultAuthorizedKeys, - fs.ModePerm, - ); err != nil { - return err - } - context.Println(tpl.SelfResolverBlockFile) if err := environment.WriteFile( env, diff --git a/cmd/dis_ssh.go b/cmd/dis_ssh.go new file mode 100644 index 0000000..dcd5f23 --- /dev/null +++ b/cmd/dis_ssh.go @@ -0,0 +1,105 @@ +package cmd + +import ( + wisski_distillery "github.com/FAU-CDI/wisski-distillery" + "github.com/FAU-CDI/wisski-distillery/internal/cli" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/pkg/environment" + "github.com/tkw1536/goprogram/exit" + + gossh "golang.org/x/crypto/ssh" +) + +// DisSSH is the 'dis_ssh' command +var DisSSH wisski_distillery.Command = disSSH{} + +type disSSH struct { + Add bool `short:"a" long:"add" description:"add key to user"` + Remove bool `short:"r" long:"remove" description:"remove key from user"` + Comment string `short:"c" long:"comment" description:"comment of new key"` + + Positionals struct { + User string `positional-arg-name:"USER" required:"1-1" description:"distillery username"` + Path string `positional-arg-name:"PATH" required:"1-1" description:"Path of key to add"` + } `positional-args:"true"` +} + +func (disSSH) Description() wisski_distillery.Description { + return wisski_distillery.Description{ + Requirements: cli.Requirements{ + NeedsDistillery: true, + }, + Command: "dis_ssh", + Description: "add or remove an ssh key from a user", + } +} + +func (ds disSSH) AfterParse() error { + var counter int + for _, action := range []bool{ + ds.Add, + ds.Remove, + } { + if action { + counter++ + } + } + + if counter != 1 { + return errNoActionSelected + } + + return nil +} + +func (ds disSSH) Run(context wisski_distillery.Context) error { + switch { + case ds.Add: + return ds.runAdd(context) + case ds.Remove: + return ds.runRemove(context) + } + panic("never reached") +} + +var errNoKey = exit.Error{ + Message: "unable to parse key", + ExitCode: exit.ExitCommandArguments, +} + +func (ds disSSH) parseOpts(context wisski_distillery.Context) (user *auth.AuthUser, key gossh.PublicKey, err error) { + user, err = context.Environment.Auth().User(context.Context, ds.Positionals.User) + if err != nil { + return nil, nil, err + } + + content, err := environment.ReadFile(context.Environment.Environment, ds.Positionals.Path) + if err != nil { + return nil, nil, err + } + + pk, _, _, _, err := gossh.ParseAuthorizedKey(content) + if pk == nil || err != nil { + return nil, nil, errNoKey + } + + return user, pk, nil +} + +func (ds disSSH) runAdd(context wisski_distillery.Context) error { + user, key, err := ds.parseOpts(context) + if err != nil { + return err + } + + return context.Environment.Keys().Add(context.Context, user.User.User, ds.Comment, key) +} + +func (ds disSSH) runRemove(context wisski_distillery.Context) error { + user, key, err := ds.parseOpts(context) + if err != nil { + return err + } + + return context.Environment.Keys().Remove(context.Context, user.User.User, key) +} diff --git a/cmd/server.go b/cmd/server.go index 0593cc1..3279e0e 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -5,7 +5,6 @@ import ( wisski_distillery "github.com/FAU-CDI/wisski-distillery" "github.com/FAU-CDI/wisski-distillery/internal/cli" - "github.com/FAU-CDI/wisski-distillery/pkg/cancel" "github.com/rs/zerolog" "github.com/tkw1536/goprogram/exit" ) @@ -14,9 +13,9 @@ import ( var Server wisski_distillery.Command = server{} type server struct { - Trigger bool `short:"t" long:"trigger" description:"instead of running on the existing server, simply trigger a cron run"` - Prefix string `short:"p" long:"prefix" description:"prefix to listen under"` - Bind string `short:"b" long:"bind" description:"address to listen on" default:"127.0.0.1:8888"` + Trigger bool `short:"t" long:"trigger" description:"instead of running on the existing server, simply trigger a cron run"` + Bind string `short:"b" long:"bind" description:"address to listen on" default:"127.0.0.1:8888"` + InternalBind string `short:"i" long:"internal-bind" description:"address to listen on for internal server" default:"127.0.0.1:9999"` } func (s server) Description() wisski_distillery.Description { @@ -55,35 +54,54 @@ func (s server) Run(context wisski_distillery.Context) error { } // and start the server - handler, err := dis.Control().Server(context.Context, context.Stderr) + public, internal, err := dis.Control().Server(context.Context, context.Stderr) if err != nil { return err } - zerolog.Ctx(context.Context).Info().Str("bind", s.Bind).Msg("listening") + // start the public listener + publicS := http.Server{Handler: public} + publicC := make(chan error) + { + zerolog.Ctx(context.Context).Info().Str("bind", s.Bind).Msg("listening public server") + publicL, err := dis.Still.Environment.Listen("tcp", s.Bind) + if err != nil { + return errServerListen.Wrap(err) + } + defer publicS.Shutdown(context.Context) + go func() { + publicC <- publicS.Serve(publicL) + }() + } - // create a new listener - listener, err := dis.Still.Environment.Listen("tcp", s.Bind) - if err != nil { - return errServerListen.Wrap(err) + // start the internal listener + internalS := http.Server{Handler: internal} + internalC := make(chan error) + { + zerolog.Ctx(context.Context).Info().Str("bind", s.InternalBind).Msg("listening internal server") + internalL, err := dis.Still.Environment.Listen("tcp", s.InternalBind) + if err != nil { + return errServerListen.Wrap(err) + } + defer internalS.Shutdown(context.Context) + go func() { + internalC <- internalS.Serve(internalL) + }() } go func() { <-context.Context.Done() - listener.Close() + zerolog.Ctx(context.Context).Info().Msg("shutting down server") + publicS.Shutdown(context.Context) + internalS.Shutdown(context.Context) }() - server := http.Server{ - Handler: http.StripPrefix(s.Prefix, handler), + if err2 := <-internalC; err2 != nil { + err = err2 + } + if err1 := <-publicC; err1 != nil { + err = err1 } - - err, _ = cancel.WithContext(context.Context, func(start func()) error { - start() - return server.Serve(listener) - }, func() { - zerolog.Ctx(context.Context).Info().Msg("shutting down server") - server.Shutdown(context.Context) - }) return errServerListen.Wrap(err) } diff --git a/cmd/wdcli/main.go b/cmd/wdcli/main.go index a2b85ad..6305517 100644 --- a/cmd/wdcli/main.go +++ b/cmd/wdcli/main.go @@ -54,6 +54,7 @@ func init() { // distillery auth wdcli.Register(cmd.DisUser) wdcli.Register(cmd.DisGrant) + wdcli.Register(cmd.DisSSH) // backup & cron wdcli.Register(cmd.Snapshot) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index c05ff43..016be21 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -34,11 +34,3 @@ const ResolverBlockedTXT = "resolver-blocked.txt" // //go:embed resolver-blocked.txt var DefaultResolverBlockedTXT []byte - -// AuthorizedKeys contains the default name for the 'global_authorized_keys' file -const AuthorizedKeys = "authorized_keys" - -// DefaultAuthorizedKeys contains a template for a new 'global_authorized_keys' file -// -//go:embed global_authorized_keys -var DefaultAuthorizedKeys []byte diff --git a/internal/bootstrap/global_authorized_keys b/internal/bootstrap/global_authorized_keys deleted file mode 100644 index 8e6cb01..0000000 --- a/internal/bootstrap/global_authorized_keys +++ /dev/null @@ -1,2 +0,0 @@ -# This file contains authorized_keys files valid for every repository in the distillery -# The syntax of this file is easy, one key per line, empty lines or those starting with '#' are ignored diff --git a/internal/cli/cli_notices.go b/internal/cli/cli_notices.go index 3096af4..2bf8490 100755 --- a/internal/cli/cli_notices.go +++ b/internal/cli/cli_notices.go @@ -1,7 +1,7 @@ package cli // =========================================================================================================== -// This file was generated automatically at 11-01-2023 13:28:38 using gogenlicense. +// This file was generated automatically at 15-01-2023 11:33:49 using gogenlicense. // Do not edit manually, as changes may be overwritten. // =========================================================================================================== @@ -2417,7 +2417,7 @@ package cli // # Generation // // This variable and the associated documentation have been automatically generated using the 'gogenlicense' tool. -// It was last updated at 11-01-2023 13:28:38. +// It was last updated at 15-01-2023 11:33:49. var LegalNotices string func init() { diff --git a/internal/config/config.go b/internal/config/config.go index b4d7464..3475594 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,9 +74,6 @@ type Config struct { // Public port to use for the ssh server PublicSSHPort uint16 `env:"SSH_PORT" default:"2222" parser:"port"` - // A file to be used for global authorized_keys for the ssh server. - GlobalAuthorizedKeysFile string `env:"GLOBAL_AUTHORIZED_KEYS_FILE" default:"/var/www/deploy/authorized_keys" parser:"file"` - // admin credentials for graphdb TriplestoreAdminUser string `env:"GRAPHDB_ADMIN_USER" default:"admin" parser:"nonempty"` TriplestoreAdminPassword string `env:"GRAPHDB_ADMIN_PASSWORD" default:"" parser:"nonempty"` diff --git a/internal/config/config_template b/internal/config/config_template index 435c51b..c48c2c5 100644 --- a/internal/config/config_template +++ b/internal/config/config_template @@ -55,9 +55,6 @@ DISTILLERY_BOOKKEEPING_DATABASE=distillery # This variable can be used to determine their length. PASSWORD_LENGTH=64 -# A file to be used for global authorized_keys for the ssh server. -GLOBAL_AUTHORIZED_KEYS_FILE=${AUTHORIZED_KEYS_FILE} - # the port to use for the ssh server SSH_PORT=2222 diff --git a/internal/config/domains.go b/internal/config/domains.go index b6a4d00..42601a5 100644 --- a/internal/config/domains.go +++ b/internal/config/domains.go @@ -30,6 +30,15 @@ func (cfg Config) HTTPSEnabledEnv() string { return "false" } +// HostFromSlug returns the hostname belonging to a given slug. +// When the slug is empty, returns the default (top-level) domain. +func (cfg Config) HostFromSlug(slug string) string { + if slug == "" { + return cfg.DefaultDomain + } + return fmt.Sprintf("%s.%s", slug, cfg.DefaultDomain) +} + // DefaultHostRule returns the default traefik hostname rule for this distillery. // This consists of the [DefaultDomain] as well as [ExtraDomains]. func (cfg Config) DefaultHostRule() string { @@ -43,6 +52,8 @@ func (cfg Config) DefaultHostRule() string { func (cfg Config) SlugFromHost(host string) (slug string, ok bool) { // extract an ':port' that happens to be in the host. domain, _, _ := strings.Cut(host, ":") + domain = TrimSuffixFold(domain, ".wisski") // remove optional ".wisski" ending that is used inside docker + domainL := strings.ToLower(domain) // check all the possible domain endings @@ -59,3 +70,10 @@ func (cfg Config) SlugFromHost(host string) (slug string, ok bool) { // no domain found! return "", ok } + +func TrimSuffixFold(s string, suffix string) string { + if len(s) >= len(suffix) && strings.EqualFold(s[len(s)-len(suffix):], suffix) { + return s[:len(s)-len(suffix)] + } + return s +} diff --git a/internal/config/template.go b/internal/config/template.go index 7d8aaff..ba71554 100644 --- a/internal/config/template.go +++ b/internal/config/template.go @@ -21,7 +21,6 @@ type Template struct { DefaultDomain string `env:"DEFAULT_DOMAIN"` SelfOverridesFile string `env:"SELF_OVERRIDES_FILE"` SelfResolverBlockFile string `env:"SELF_RESOLVER_BLOCK_FILE"` - AuthorizedKeys string `env:"AUTHORIZED_KEYS_FILE"` TriplestoreAdminUser string `env:"GRAPHDB_ADMIN_USER"` TriplestoreAdminPassword string `env:"GRAPHDB_ADMIN_PASSWORD"` MysqlAdminUsername string `env:"MYSQL_ADMIN_USER"` @@ -48,10 +47,6 @@ func (tpl *Template) SetDefaults(env environment.Environment) (err error) { tpl.SelfResolverBlockFile = filepath.Join(tpl.DeployRoot, bootstrap.ResolverBlockedTXT) } - if tpl.AuthorizedKeys == "" { - tpl.AuthorizedKeys = filepath.Join(tpl.DeployRoot, bootstrap.AuthorizedKeys) - } - if tpl.TriplestoreAdminUser == "" { tpl.TriplestoreAdminUser = "admin" } diff --git a/internal/dis/component/auth/panel/panel.go b/internal/dis/component/auth/panel/panel.go index 4d556e4..96395e4 100644 --- a/internal/dis/component/auth/panel/panel.go +++ b/internal/dis/component/auth/panel/panel.go @@ -10,6 +10,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys" "github.com/FAU-CDI/wisski-distillery/internal/models" "github.com/FAU-CDI/wisski-distillery/pkg/httpx" "github.com/julienschmidt/httprouter" @@ -23,6 +24,7 @@ type UserPanel struct { Policy *policy.Policy Instances *instances.Instances Next *next.Next + Keys *sshkeys.SSHKeys } } @@ -73,6 +75,22 @@ func (panel *UserPanel) HandleRoute(ctx context.Context, route string) (http.Han router.Handler(http.MethodPost, route+"totp/disable", totpdisable) } + { + ssh := panel.sshRoute(ctx) + router.Handler(http.MethodGet, route+"ssh", ssh) + } + + { + add := panel.sshAddRoute(ctx) + router.Handler(http.MethodGet, route+"ssh/add", add) + router.Handler(http.MethodPost, route+"ssh/add", add) + } + + { + delete := panel.sshDeleteRoute(ctx) + router.Handler(http.MethodPost, route+"ssh/delete", delete) + } + // ensure that the user is logged in! return panel.Dependencies.Auth.Protect(router, nil), nil } diff --git a/internal/dis/component/auth/panel/ssh.go b/internal/dis/component/auth/panel/ssh.go new file mode 100644 index 0000000..e2849dc --- /dev/null +++ b/internal/dis/component/auth/panel/ssh.go @@ -0,0 +1,193 @@ +package panel + +import ( + "context" + "errors" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom" + "github.com/FAU-CDI/wisski-distillery/internal/models" + "github.com/FAU-CDI/wisski-distillery/pkg/httpx" + "github.com/FAU-CDI/wisski-distillery/pkg/httpx/field" + "github.com/gliderlabs/ssh" + "github.com/rs/zerolog" + + gossh "golang.org/x/crypto/ssh" + + _ "embed" +) + +//go:embed "templates/ssh.html" +var sshTemplateStr string +var sshTemplate = static.AssetsUser.MustParseShared("ssh.html", sshTemplateStr) + +type SSHTemplateContext struct { + custom.BaseContext + + Keys []models.Keys + + Domain string // domain name of the distillery + Port uint16 // public port of the distillery ssh servers + + Slug string // slug of the wisski + Hostname string // hostname of an example wisski +} + +func (panel *UserPanel) sshRoute(ctx context.Context) http.Handler { + sshTemplate := panel.Dependencies.Custom.Template(sshTemplate) + gaps := custom.BaseContextGaps{ + Crumbs: []component.MenuItem{ + {Title: "User", Path: "/user/"}, + {Title: "SSH Keys", Path: "/user/ssh/"}, + }, + Actions: []component.MenuItem{ + {Title: "Add New Key", Path: "/user/ssh/add/"}, + }, + } + + return httpx.HTMLHandler[SSHTemplateContext]{ + Handler: func(r *http.Request) (sc SSHTemplateContext, err error) { + panel.Dependencies.Custom.Update(&sc, r, gaps) + + user, err := panel.Dependencies.Auth.UserOf(r) + if err != nil { + return sc, err + } + + sc.Domain = panel.Config.DefaultDomain + sc.Port = panel.Config.PublicSSHPort + + // pick the first domain that the user has access to as an example + grants, err := panel.Dependencies.Policy.User(r.Context(), user.User.User) + if err != nil && len(grants) > 0 { + sc.Slug = grants[0].Slug + } else { + sc.Slug = "example" + } + sc.Hostname = panel.Config.HostFromSlug(sc.Slug) + + sc.Keys, err = panel.Dependencies.Keys.Keys(r.Context(), user.User.User) + if err != nil { + return sc, err + } + + return sc, nil + }, + Template: sshTemplate, + } +} + +//go:embed "templates/ssh_add.html" +var sshAddTemplateStr string +var sshAddTemplate = static.AssetsUser.MustParseShared("ssh_add.html", sshAddTemplateStr) + +type addKeyResult struct { + User *auth.AuthUser + Comment string + Key ssh.PublicKey +} + +var ( + errInvalidUser = errors.New("invalid user") + errKeyParse = errors.New("unable to parse ssh key") + errAddKey = errors.New("unable to add key") +) + +func (panel *UserPanel) sshDeleteRoute(ctx context.Context) http.Handler { + logger := zerolog.Ctx(ctx) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + logger.Err(err).Str("action", "delete ssh key").Msg("failed to parse form") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + user, err := panel.Dependencies.Auth.UserOf(r) + if err != nil { + logger.Err(err).Str("action", "delete ssh key").Msg("failed to get current user") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + + key, _ := parseKey(r.PostFormValue("signature")) + if key == nil { + logger.Err(err).Str("action", "delete ssh key").Msg("failed to parse signature") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + + if err := panel.Dependencies.Keys.Remove(r.Context(), user.User.User, key); err != nil { + logger.Err(err).Str("action", "delete ssh key").Msg("failed to delete key") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + + http.Redirect(w, r, "/user/ssh/", http.StatusSeeOther) + }) +} + +func (panel *UserPanel) sshAddRoute(ctx context.Context) http.Handler { + sshAddTemplate := panel.Dependencies.Custom.Template(sshAddTemplate) + gaps := custom.BaseContextGaps{ + Crumbs: []component.MenuItem{ + {Title: "User", Path: "/user/"}, + {Title: "SSH Keys", Path: "/user/ssh/"}, + {Title: "Add New Key", Path: "/user/ssh/add/"}, + }, + } + + return &httpx.Form[addKeyResult]{ + Fields: []field.Field{ + {Name: "comment", Type: field.Text, Label: "Comment"}, + {Name: "key", Type: field.Textarea, Label: "Key in authorized_keys format"}, // has hacked css! + }, + FieldTemplate: field.PureCSSFieldTemplate, + + RenderTemplate: sshAddTemplate, + RenderTemplateContext: func(ctx httpx.FormContext, r *http.Request) any { + return panel.Dependencies.Custom.NewForm(ctx, r, gaps) + }, + + Validate: func(r *http.Request, values map[string]string) (ak addKeyResult, err error) { + ak.User, err = panel.Dependencies.Auth.UserOf(r) + if err != nil || ak.User == nil { + return ak, errInvalidUser + } + + // parse key and comment + var key, comment string + ak.Comment, key = values["comment"], values["key"] + ak.Key, comment = parseKey(key) + if ak.Key == nil { + return ak, errKeyParse + } + + // set the comment if the user didn't provide one! + if ak.Comment == "" && comment != "" { + ak.Comment = comment + } + return ak, nil + }, + + RenderSuccess: func(ak addKeyResult, values map[string]string, w http.ResponseWriter, r *http.Request) error { + // add the key to the user + if err := panel.Dependencies.Keys.Add(r.Context(), ak.User.User.User, ak.Comment, ak.Key); err != nil { + return errAddKey + } + // everything went fine, redirect the user back to the user page! + http.Redirect(w, r, "/user/ssh/", http.StatusSeeOther) + return nil + }, + } +} + +func parseKey(authorized_keys string) (out gossh.PublicKey, comment string) { + var err error + out, comment, _, _, err = gossh.ParseAuthorizedKey([]byte(authorized_keys)) + if err != nil || out == nil { + return nil, "" + } + return out, comment +} diff --git a/internal/dis/component/auth/panel/templates/ssh.html b/internal/dis/component/auth/panel/templates/ssh.html new file mode 100644 index 0000000..9f20edd --- /dev/null +++ b/internal/dis/component/auth/panel/templates/ssh.html @@ -0,0 +1,104 @@ +{{ template "_base.html" . }} +{{ define "title" }}SSH Keys{{ end }} + +{{ define "content" }} + +
+

+ This page allows you to add, view and remove ssh keys to and from your distillery account. +

+
+ +
+

+ This table shows ssh keys currently associated with your account. + To add a new key, use the Add New Key button above. + To remove an ssh key from your account, simply click the Delete button. +

+
+
+ + + + + + + + + + + {{ $csrf := .CSRF }} + {{ range .Keys }} + {{ $sig := .SignatureString }} + + + + + + {{ end }} + +
+ Comment + + Signature + + Actions +
+ {{ .Comment }} + + + {{ $sig }} + + +
+
+ + + {{ $csrf }} +
+
+
+
+
+
+ + +
+

+ You can use these ssh keys to connect to the distillery via ssh. + You can only connect to instances for which you appear as an Administrator on your user page. +

+

+ In the following we will provide instructions on how to connect to your WissKI instance via the distillery server. + In the following we will assume {{ .Slug }} is the name of the WissKI you want to you want to connect to. +

+

+ From a Linux (or Mac, or Windows 11) command line you may use: +

+ + ssh -J {{ .Domain }}:{{ .Port }} www-data@{{ .Hostname }} + +

+ You may also place the following into your $HOME/.ssh/config file: +

+ +
Host *.{{ .Domain }}
+    ProxyJump {{ .Domain }}.proxy
+    User www-data
+Host {{ .Domain }}.proxy
+    User www-data
+    Hostname {{ .Domain }}
+    Port {{ .Port }}
+
+
+ +

+ and then connect simply via: +

+ + + ssh {{ .Hostname }} + +
+ +{{ end }} \ No newline at end of file diff --git a/internal/dis/component/auth/panel/templates/ssh_add.html b/internal/dis/component/auth/panel/templates/ssh_add.html new file mode 100644 index 0000000..15ae52e --- /dev/null +++ b/internal/dis/component/auth/panel/templates/ssh_add.html @@ -0,0 +1,11 @@ +{{ template "_form.html" . }} +{{ define "form/title" }}Add SSH Key{{ end }} +{{ define "form/button" }}Add{{ end }} + +{{ define "form/inside" }} +
+

+ Use this form to add a new SSH Key to your account. +

+
+{{ end }} \ No newline at end of file diff --git a/internal/dis/component/auth/panel/user.go b/internal/dis/component/auth/panel/user.go index 45da2ae..f9c7ef7 100644 --- a/internal/dis/component/auth/panel/user.go +++ b/internal/dis/component/auth/panel/user.go @@ -41,13 +41,14 @@ func (panel *UserPanel) routeUser(ctx context.Context) http.Handler { {Title: "User", Path: "/user/"}, }, Actions: []component.MenuItem{ - {Title: "Change Password", Path: "/user/password"}, + {Title: "Change Password", Path: "/user/password/"}, + {Title: "*to be replaced*", Path: ""}, + {Title: "SSH Keys", Path: "/user/ssh/"}, }, } return &httpx.HTMLHandler[routeUserContext]{ Handler: func(r *http.Request) (ruc routeUserContext, err error) { - // find the user ruc.AuthUser, err = panel.Dependencies.Auth.UserOf(r) if err != nil || ruc.AuthUser == nil { @@ -57,15 +58,15 @@ func (panel *UserPanel) routeUser(ctx context.Context) http.Handler { // build the gaps gaps := gaps.Clone() if ruc.AuthUser.IsTOTPEnabled() { - gaps.Actions = append(gaps.Actions, component.MenuItem{ + gaps.Actions[1] = component.MenuItem{ Title: "Disable Passcode (TOTP)", Path: "/user/totp/disable/", - }) + } } else { - gaps.Actions = append(gaps.Actions, component.MenuItem{ + gaps.Actions[1] = component.MenuItem{ Title: "Enable Passcode (TOTP)", Path: "/user/totp/enable/", - }) + } } panel.Dependencies.Custom.Update(&ruc, r, gaps) diff --git a/internal/dis/component/control/admin/html/index.html b/internal/dis/component/control/admin/html/index.html index 2e2f74d..bea47e7 100644 --- a/internal/dis/component/control/admin/html/index.html +++ b/internal/dis/component/control/admin/html/index.html @@ -134,14 +134,6 @@ {{.Config.ConfigPath}} - - - authorized_keys - - - {{.Config.GlobalAuthorizedKeysFile}} - - diff --git a/internal/dis/component/control/admin/users.go b/internal/dis/component/control/admin/users.go index f57ad8f..575d813 100644 --- a/internal/dis/component/control/admin/users.go +++ b/internal/dis/component/control/admin/users.go @@ -37,6 +37,9 @@ func (admin *Admin) users(r *http.Request) (uc userContext, err error) { {Title: "Admin", Path: "/admin/"}, {Title: "Users", Path: "/admin/users/"}, }, + Actions: []component.MenuItem{ + {Title: "Create New", Path: "/admin/users/create/"}, + }, }) uc.Error = r.URL.Query().Get("error") @@ -70,9 +73,6 @@ func (admin *Admin) createUser(ctx context.Context) http.Handler { {Title: "Users", Path: "/admin/users"}, {Title: "Create", Path: "/admin/users/create"}, }, - Actions: []component.MenuItem{ - {Title: "Create New", Path: "/admin/users/create/"}, - }, } return &httpx.Form[createUserResult]{ diff --git a/internal/dis/component/control/control.env b/internal/dis/component/control/control.env index 6a42245..e8d8e43 100644 --- a/internal/dis/component/control/control.env +++ b/internal/dis/component/control/control.env @@ -2,7 +2,6 @@ HOST_RULE=${HOST_RULE} CONFIG_PATH=${CONFIG_PATH} DEPLOY_ROOT=${DEPLOY_ROOT} -GLOBAL_AUTHORIZED_KEYS_FILE=${GLOBAL_AUTHORIZED_KEYS_FILE} SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE} SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE} diff --git a/internal/dis/component/control/control.go b/internal/dis/component/control/control.go index 9dd9c12..697be39 100644 --- a/internal/dis/component/control/control.go +++ b/internal/dis/component/control/control.go @@ -49,9 +49,8 @@ func (control *Control) Stack(env environment.Environment) component.StackWithRe "CONFIG_PATH": control.Config.ConfigPath, "DEPLOY_ROOT": control.Config.DeployRoot, - "GLOBAL_AUTHORIZED_KEYS_FILE": control.Config.GlobalAuthorizedKeysFile, - "SELF_OVERRIDES_FILE": control.Config.SelfOverridesFile, - "SELF_RESOLVER_BLOCK_FILE": control.Config.SelfResolverBlockFile, + "SELF_OVERRIDES_FILE": control.Config.SelfOverridesFile, + "SELF_RESOLVER_BLOCK_FILE": control.Config.SelfResolverBlockFile, "CUSTOM_ASSETS_PATH": control.Dependencies.Custom.CustomAssetsPath(), }, diff --git a/internal/dis/component/control/control/Dockerfile b/internal/dis/component/control/control/Dockerfile index 682bba1..1fdbb59 100644 --- a/internal/dis/component/control/control/Dockerfile +++ b/internal/dis/component/control/control/Dockerfile @@ -2,4 +2,4 @@ FROM docker.io/library/docker:20.10-cli COPY wdcli /wdcli EXPOSE 8888 -CMD ["/wdcli","--internal-in-docker","server","--bind","0.0.0.0:8888"] \ No newline at end of file +CMD ["/wdcli","--internal-in-docker","server","--bind","0.0.0.0:8888", "--internal-bind", "0.0.0.0:9999"] \ No newline at end of file diff --git a/internal/dis/component/control/control/docker-compose.yml b/internal/dis/component/control/control/docker-compose.yml index b1f15c0..46581d2 100644 --- a/internal/dis/component/control/control/docker-compose.yml +++ b/internal/dis/component/control/control/docker-compose.yml @@ -25,7 +25,6 @@ services: - "/var/run/docker.sock:/var/run/docker.sock" - "${CONFIG_PATH}:${CONFIG_PATH}:ro" - "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw" - - "${GLOBAL_AUTHORIZED_KEYS_FILE}:${GLOBAL_AUTHORIZED_KEYS_FILE}:ro" - "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro" - "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro" - "${CUSTOM_ASSETS_PATH}:${CUSTOM_ASSETS_PATH}:ro" diff --git a/internal/dis/component/control/news/NEWS/2023-01-15-news.md b/internal/dis/component/control/news/NEWS/2023-01-15-news.md new file mode 100644 index 0000000..6cf8e73 --- /dev/null +++ b/internal/dis/component/control/news/NEWS/2023-01-15-news.md @@ -0,0 +1,9 @@ +--- +title: Reworked SSH key support +date: 2023-01-15 +--- + +- reworked and added ssh key management to the server + - users can now add and remove ssh keys to their account + - each user with an admin grant for a specific instance has ssh access via their keys + - distillery administrators have implicit access to all instances diff --git a/internal/dis/component/control/server.go b/internal/dis/component/control/server.go index 37e2d93..f97dac4 100644 --- a/internal/dis/component/control/server.go +++ b/internal/dis/component/control/server.go @@ -18,17 +18,17 @@ import ( // The server may spawn background tasks, but these should be terminated once context closes. // // Logging messages are directed to progress -func (control *Control) Server(ctx context.Context, progress io.Writer) (http.Handler, error) { +func (control *Control) Server(ctx context.Context, progress io.Writer) (public http.Handler, internal http.Handler, err error) { logger := zerolog.Ctx(ctx) - var mux mux.Mux[component.RouteContext] - mux.Context = func(r *http.Request) component.RouteContext { + var publicM, internalM mux.Mux[component.RouteContext] + publicM.Context = func(r *http.Request) component.RouteContext { slug, ok := control.Still.Config.SlugFromHost(r.Host) return component.RouteContext{ DefaultDomain: slug == "" && ok, } } - mux.Panic = func(panic any, w http.ResponseWriter, r *http.Request) { + publicM.Panic = func(panic any, w http.ResponseWriter, r *http.Request) { // log the panic logger.Error(). Str("panic", fmt.Sprint(panic)). @@ -39,6 +39,10 @@ func (control *Control) Server(ctx context.Context, progress io.Writer) (http.Ha httpx.TextInterceptor.Fallback.ServeHTTP(w, r) } + // setup the internal server identically + internalM.Panic = publicM.Panic + internalM.Context = publicM.Context + // create a csrf protector csrfProtector := control.CSRF() @@ -52,6 +56,7 @@ func (control *Control) Server(ctx context.Context, progress io.Writer) (http.Ha Bool("Exact", routes.Exact). Bool("CSRF", routes.CSRF). Bool("Decorator", routes.Decorator != nil). + Bool("Internal", routes.Internal). Bool("MatchAllDomains", routes.MatchAllDomains). Msg("mounting route") @@ -69,19 +74,23 @@ func (control *Control) Server(ctx context.Context, progress io.Writer) (http.Ha handler = routes.Decorate(handler, csrfProtector) // determine the predicate - predicate := routes.Predicate(mux.ContextOf) + predicate := routes.Predicate(publicM.ContextOf) // and add all the prefixes for _, prefix := range append([]string{routes.Prefix}, routes.Aliases...) { - mux.Add(prefix, predicate, routes.Exact, handler) + if routes.Internal { + internalM.Add(prefix, predicate, routes.Exact, handler) + } else { + publicM.Add(prefix, predicate, routes.Exact, handler) + } } } // apply the given context function - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r = r.WithContext(cancel.ValuesOf(r.Context(), ctx)) - mux.ServeHTTP(w, r) - }), nil + public = httpx.WithContextWrapper(&publicM, func(rcontext context.Context) context.Context { return cancel.ValuesOf(rcontext, ctx) }) + internal = httpx.WithContextWrapper(&internalM, func(rcontext context.Context) context.Context { return cancel.ValuesOf(rcontext, ctx) }) + err = nil + return } // CSRF returns a CSRF handler for the given function diff --git a/internal/dis/component/control/static/assets_dist.go b/internal/dis/component/control/static/assets_dist.go index db552a8..e38a4ca 100644 --- a/internal/dis/component/control/static/assets_dist.go +++ b/internal/dis/component/control/static/assets_dist.go @@ -15,12 +15,12 @@ var AssetsDefault = Assets{ // AssetsUser contains assets for the 'User' entrypoint. var AssetsUser = Assets{ - Scripts: ``, - Styles: ``, + Scripts: ``, + Styles: ``, } // AssetsAdmin contains assets for the 'Admin' entrypoint. var AssetsAdmin = Assets{ - Scripts: ``, - Styles: ``, + Scripts: ``, + Styles: ``, } diff --git a/internal/dis/component/control/static/dist/User.30d54198.js b/internal/dis/component/control/static/dist/User.30d54198.js deleted file mode 100644 index 652e536..0000000 --- a/internal/dis/component/control/static/dist/User.30d54198.js +++ /dev/null @@ -1 +0,0 @@ -!function(){var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},n={},r={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in n)return n[e].exports;if(e in r){var o=r[e];delete r[e];var i={id:e,exports:{}};return n[e]=i,o.call(i.exports,i,i.exports),i.exports}var t=new Error("Cannot find module '"+e+"'");throw t.code="MODULE_NOT_FOUND",t}).register=function(e,n){r[e]=n},e.parcelRequireafa4=o),o.register("kEAtK",(function(e,n){})),o("kEAtK")}(); \ No newline at end of file diff --git a/internal/dis/component/control/static/dist/User.38d394c2.css b/internal/dis/component/control/static/dist/User.38d394c2.css deleted file mode 100644 index e69de29..0000000 diff --git a/internal/dis/component/control/static/dist/User.4197014b.js b/internal/dis/component/control/static/dist/User.4197014b.js deleted file mode 100644 index 11a38e5..0000000 --- a/internal/dis/component/control/static/dist/User.4197014b.js +++ /dev/null @@ -1 +0,0 @@ -var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},n={},r={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in n)return n[e].exports;if(e in r){var o=r[e];delete r[e];var i={id:e,exports:{}};return n[e]=i,o.call(i.exports,i,i.exports),i.exports}var t=new Error("Cannot find module '"+e+"'");throw t.code="MODULE_NOT_FOUND",t}).register=function(e,n){r[e]=n},e.parcelRequireafa4=o),o.register("gkpdw",(function(e,n){})),o("gkpdw"); \ No newline at end of file diff --git a/internal/dis/component/control/static/dist/User.68febbf8.css b/internal/dis/component/control/static/dist/User.68febbf8.css new file mode 100644 index 0000000..cc69509 --- /dev/null +++ b/internal/dis/component/control/static/dist/User.68febbf8.css @@ -0,0 +1 @@ +.copy{-webkit-user-select:all;user-select:all} \ No newline at end of file diff --git a/internal/dis/component/control/static/dist/User.840de3b4.css b/internal/dis/component/control/static/dist/User.840de3b4.css new file mode 100644 index 0000000..88179c1 --- /dev/null +++ b/internal/dis/component/control/static/dist/User.840de3b4.css @@ -0,0 +1 @@ +textarea#key{width:50%;height:10em;resize:both} \ No newline at end of file diff --git a/internal/dis/component/control/static/dist/User.b2f9a57c.js b/internal/dis/component/control/static/dist/User.b2f9a57c.js new file mode 100644 index 0000000..1636ecd --- /dev/null +++ b/internal/dis/component/control/static/dist/User.b2f9a57c.js @@ -0,0 +1 @@ +!function(){var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},r={},n={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in r)return r[e].exports;if(e in n){var o=n[e];delete n[e];var t={id:e,exports:{}};return r[e]=t,o.call(t.exports,t,t.exports),t.exports}var i=new Error("Cannot find module '"+e+"'");throw i.code="MODULE_NOT_FOUND",i}).register=function(e,r){n[e]=r},e.parcelRequireafa4=o),o.register("kEAtK",(function(e,r){o("15EWx")})),o.register("15EWx",(function(e,r){document.querySelectorAll(".copy").forEach((e=>{e.addEventListener("click",(()=>{navigator.clipboard&&navigator.clipboard.writeText(e.innerText)}))}))})),o("kEAtK")}(); \ No newline at end of file diff --git a/internal/dis/component/control/static/dist/User.e0367d79.js b/internal/dis/component/control/static/dist/User.e0367d79.js new file mode 100644 index 0000000..fd56a8a --- /dev/null +++ b/internal/dis/component/control/static/dist/User.e0367d79.js @@ -0,0 +1 @@ +var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},r={},n={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in r)return r[e].exports;if(e in n){var o=n[e];delete n[e];var i={id:e,exports:{}};return r[e]=i,o.call(i.exports,i,i.exports),i.exports}var t=new Error("Cannot find module '"+e+"'");throw t.code="MODULE_NOT_FOUND",t}).register=function(e,r){n[e]=r},e.parcelRequireafa4=o),o.register("gkpdw",(function(e,r){o("hZNgY")})),o.register("hZNgY",(function(e,r){document.querySelectorAll(".copy").forEach((e=>{e.addEventListener("click",(()=>{navigator.clipboard&&navigator.clipboard.writeText(e.innerText)}))}))})),o("gkpdw"); \ No newline at end of file diff --git a/internal/dis/component/control/static/src/entry/User/index.css b/internal/dis/component/control/static/src/entry/User/index.css index 6c0dca4..77fb7a7 100644 --- a/internal/dis/component/control/static/src/entry/User/index.css +++ b/internal/dis/component/control/static/src/entry/User/index.css @@ -1 +1,6 @@ -/* nothing for now */ \ No newline at end of file +/* textarea on the /user/ssh/add form */ +textarea#key { + width: 50%; + height: 10em; + resize: both; +} diff --git a/internal/dis/component/control/static/src/entry/User/index.ts b/internal/dis/component/control/static/src/entry/User/index.ts index b0acb4c..fe3b334 100644 --- a/internal/dis/component/control/static/src/entry/User/index.ts +++ b/internal/dis/component/control/static/src/entry/User/index.ts @@ -1 +1 @@ -// nothing for now \ No newline at end of file +import "~/src/lib/copy" \ No newline at end of file diff --git a/internal/dis/component/control/static/src/lib/copy/index.css b/internal/dis/component/control/static/src/lib/copy/index.css new file mode 100644 index 0000000..9cdd4ff --- /dev/null +++ b/internal/dis/component/control/static/src/lib/copy/index.css @@ -0,0 +1,3 @@ +.copy { + user-select: all; +} \ No newline at end of file diff --git a/internal/dis/component/control/static/src/lib/copy/index.ts b/internal/dis/component/control/static/src/lib/copy/index.ts new file mode 100644 index 0000000..b6cff5e --- /dev/null +++ b/internal/dis/component/control/static/src/lib/copy/index.ts @@ -0,0 +1,8 @@ +import "./index.css" + +document.querySelectorAll('.copy').forEach((elem: Element) => { + elem.addEventListener('click', () => { + if (!navigator.clipboard) return; + navigator.clipboard.writeText((elem as HTMLElement).innerText); + }) +}) \ No newline at end of file diff --git a/internal/dis/component/exporter/extras_config.go b/internal/dis/component/exporter/extras_config.go index a8930e4..f91cdc6 100644 --- a/internal/dis/component/exporter/extras_config.go +++ b/internal/dis/component/exporter/extras_config.go @@ -41,6 +41,5 @@ func (control *Config) backupFiles() []string { control.Config.ExecutablePath(), control.Config.SelfOverridesFile, control.Config.SelfResolverBlockFile, - control.Config.GlobalAuthorizedKeysFile, } } diff --git a/internal/dis/component/instances/malt/malt.go b/internal/dis/component/instances/malt/malt.go index 4949bbc..c03333b 100644 --- a/internal/dis/component/instances/malt/malt.go +++ b/internal/dis/component/instances/malt/malt.go @@ -6,6 +6,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/meta" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/triplestore" ) @@ -21,4 +22,6 @@ type Malt struct { Meta *meta.Meta `auto:"true"` ExporterLog *logger.Logger `auto:"true"` Policy *policy.Policy `auto:"true"` + + Keys *sshkeys.SSHKeys `auto:"true"` } diff --git a/internal/dis/component/server.go b/internal/dis/component/server.go index dbe2baf..a4f8aff 100644 --- a/internal/dis/component/server.go +++ b/internal/dis/component/server.go @@ -26,6 +26,10 @@ type Routes struct { // MatchAllDomains indicates that all domains, even the non-default domain, should be matched MatchAllDomains bool + // Internal indicates that this route should only answer on the internal server. + // Internal implies MatchAllDomains. + Internal bool + // MenuTitle and MenuPriority return the priority and title of this menu item MenuTitle string MenuPriority MenuPriority @@ -52,7 +56,7 @@ type RouteContext struct { // Predicate returns the predicate corresponding to the given route func (routes Routes) Predicate(context func(*http.Request) RouteContext) mux.Predicate { - if routes.MatchAllDomains { + if routes.MatchAllDomains || routes.Internal { return nil } diff --git a/internal/dis/component/ssh2/api.go b/internal/dis/component/ssh2/api.go new file mode 100644 index 0000000..dc0da1f --- /dev/null +++ b/internal/dis/component/ssh2/api.go @@ -0,0 +1,58 @@ +package ssh2 + +import ( + "context" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/pkg/httpx" + gossh "golang.org/x/crypto/ssh" +) + +func (ssh2 *SSH2) Routes() component.Routes { + return component.Routes{ + Prefix: "/authorized_keys/", + Exact: true, + Internal: true, + } +} +func (ssh2 *SSH2) HandleRoute(ctx context.Context, path string) (http.Handler, error) { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // fetch the global keys + gkeys, err := ssh2.Dependencies.Keys.Admin(r.Context()) + if err != nil { + httpx.TextInterceptor.Intercept(w, r, err) + return + } + + // find the host + slug, ok := ssh2.Config.SlugFromHost(r.Host) + if slug == "" || !ok { + httpx.TextInterceptor.Intercept(w, r, httpx.ErrNotFound) + return + } + + // fetch the instance + instance, err := ssh2.Dependencies.Instances.WissKI(r.Context(), slug) + if err != nil { + httpx.TextInterceptor.Intercept(w, r, httpx.ErrNotFound) + return + } + + // fetch the instance keys + keys, err := instance.SSH().Keys(r.Context()) + if err != nil { + httpx.TextInterceptor.Intercept(w, r, err) + return + } + + // marshal out everything! + for _, key := range gkeys { + w.Write(gossh.MarshalAuthorizedKey(key)) + } + for _, key := range keys { + w.Write(gossh.MarshalAuthorizedKey(key)) + } + + }), nil +} diff --git a/internal/dis/component/ssh2/server_auth.go b/internal/dis/component/ssh2/server_auth.go index 8c4be82..cdece99 100644 --- a/internal/dis/component/ssh2/server_auth.go +++ b/internal/dis/component/ssh2/server_auth.go @@ -1,8 +1,7 @@ package ssh2 import ( - "time" - + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys" "github.com/gliderlabs/ssh" ) @@ -47,19 +46,17 @@ func getAnyPermission(context ssh.Context) (string, bool) { return "", (false || value[""]) } -const authDelay = time.Second / 10 - func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool { - return slowdown(func() (ok bool) { + return sshkeys.Slowdown(func() (ok bool) { permissions := make(map[string]bool) // grab the global permissions { - globalKeys, err := ssh2.GlobalKeys() + globalKeys, err := ssh2.Dependencies.Keys.Admin(ctx) if err != nil { return false } - permissions[""] = isKey(globalKeys, key) + permissions[""] = sshkeys.KeyOneOf(globalKeys, key) ok = permissions[""] } @@ -71,11 +68,11 @@ func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool { } for _, instance := range instances { - ikeys, err := instance.SSH().Keys() + ikeys, err := instance.SSH().Keys(ctx) if err != nil { continue } - access := isKey(ikeys, key) + access := sshkeys.KeyOneOf(ikeys, key) permissions[instance.Slug] = access || permissions[""] ok = ok || access @@ -84,27 +81,5 @@ func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool { setPermissions(ctx, permissions) return - }, authDelay) -} - -// slowdown invokes f immediatly, but only returns the result to the caller after at least duration. -// It can be used to prevent timing attacks -func slowdown[T any](f func() T, duration time.Duration) T { - result := make(chan T, 1) - go func() { - result <- f() - }() - time.Sleep(duration) - return <-result -} - -// isKey checks if keys contains key in O(len(keys)) -func isKey(keys []ssh.PublicKey, key ssh.PublicKey) bool { - var res bool - for _, ak := range keys { - if ssh.KeysEqual(ak, key) { - res = true - } - } - return res + }) } diff --git a/internal/dis/component/ssh2/ssh2.env b/internal/dis/component/ssh2/ssh2.env index 9e40b5d..886eb36 100644 --- a/internal/dis/component/ssh2/ssh2.env +++ b/internal/dis/component/ssh2/ssh2.env @@ -2,7 +2,6 @@ HOST_RULE=${HOST_RULE} CONFIG_PATH=${CONFIG_PATH} DEPLOY_ROOT=${DEPLOY_ROOT} -GLOBAL_AUTHORIZED_KEYS_FILE=${GLOBAL_AUTHORIZED_KEYS_FILE} SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE} SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE} diff --git a/internal/dis/component/ssh2/ssh2.go b/internal/dis/component/ssh2/ssh2.go index e4e28c3..fa93c90 100644 --- a/internal/dis/component/ssh2/ssh2.go +++ b/internal/dis/component/ssh2/ssh2.go @@ -1,34 +1,24 @@ package ssh2 import ( - "io" - "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" - "github.com/FAU-CDI/wisski-distillery/pkg/sshx" - "github.com/gliderlabs/ssh" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys" ) type SSH2 struct { component.Base Dependencies struct { + SQL *sql.SQL Instances *instances.Instances + Auth *auth.Auth + Keys *sshkeys.SSHKeys } } var ( _ component.Installable = (*SSH2)(nil) + _ component.Routeable = (*SSH2)(nil) ) - -// GlobalKeys returns the global authorized keys -func (s *SSH2) GlobalKeys() ([]ssh.PublicKey, error) { - file, err := s.Environment.Open(s.Config.GlobalAuthorizedKeysFile) - if err != nil { - return nil, err - } - bytes, err := io.ReadAll(file) - if err != nil { - return nil, err - } - return sshx.ParseAllKeys(bytes), nil -} diff --git a/internal/dis/component/ssh2/ssh2/docker-compose.yml b/internal/dis/component/ssh2/ssh2/docker-compose.yml index 4272501..5686e68 100644 --- a/internal/dis/component/ssh2/ssh2/docker-compose.yml +++ b/internal/dis/component/ssh2/ssh2/docker-compose.yml @@ -12,7 +12,6 @@ services: - "/var/run/docker.sock:/var/run/docker.sock" - "${CONFIG_PATH}:${CONFIG_PATH}:ro" - "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw" - - "${GLOBAL_AUTHORIZED_KEYS_FILE}:${GLOBAL_AUTHORIZED_KEYS_FILE}:ro" - "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro" - "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro" - "./data/:/data/" diff --git a/internal/dis/component/ssh2/sshkeys/sshkeys.go b/internal/dis/component/ssh2/sshkeys/sshkeys.go new file mode 100644 index 0000000..5e49835 --- /dev/null +++ b/internal/dis/component/ssh2/sshkeys/sshkeys.go @@ -0,0 +1,51 @@ +package sshkeys + +import ( + "context" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" + "github.com/gliderlabs/ssh" +) + +type SSHKeys struct { + component.Base + Dependencies struct { + SQL *sql.SQL + Auth *auth.Auth + } +} + +var ( + _ component.Table = (*SSHKeys)(nil) + _ component.UserDeleteHook = (*SSHKeys)(nil) +) + +// Admin returns the set of administrative ssh keys. +// These are ssh keys associated to distillery admin users. +func (k *SSHKeys) Admin(ctx context.Context) (keys []ssh.PublicKey, err error) { + users, err := k.Dependencies.Auth.Users(ctx) + if err != nil { + return nil, err + } + + // iterate over enabled distillery admin users + for _, user := range users { + if !user.IsEnabled() || !user.IsAdmin() { + continue + } + ukeys, err := k.Keys(ctx, user.User.User) + if err != nil { + return nil, err + } + for _, ukey := range ukeys { + if pk := ukey.PublicKey(); pk != nil { + keys = append(keys, pk) + } + } + } + + // and return the keys! + return keys, nil +} diff --git a/internal/dis/component/ssh2/sshkeys/subtle.go b/internal/dis/component/ssh2/sshkeys/subtle.go new file mode 100644 index 0000000..54012a2 --- /dev/null +++ b/internal/dis/component/ssh2/sshkeys/subtle.go @@ -0,0 +1,55 @@ +package sshkeys + +import ( + "crypto/rand" + "math/big" + "time" + + "github.com/gliderlabs/ssh" +) + +// KeyOneOf checks if keys is one of the given set of keys. +func KeyOneOf(keys []ssh.PublicKey, key ssh.PublicKey) bool { + return len(KeyIndexes(keys, key)) > 0 +} + +// KeyIndexes returns a slice of ints that contain the indexes of the given key. +func KeyIndexes(keys []ssh.PublicKey, key ssh.PublicKey) []int { + indexes := make([]int, 0, len(keys)) + for i, cey := range keys { + if ssh.KeysEqual(key, cey) { + indexes = append(indexes, i) + } + } + return indexes +} + +const ( + slowdownMinDelay = time.Second / 10 + slowdownJitter = time.Second / 10 +) + +// slowdown invokes f immediatly, but introduces a random delay to prevent timing attacks. +// the delay is also introduced if f() panics. +func Slowdown[T any](f func() T) T { + start := time.Now() + defer func() { + // sleep the minimum remaining time + remain := time.Since(start) - slowdownMinDelay + if remain > 0 { + time.Sleep(remain) + } + + // find a second random delay + delay, err := rand.Int(rand.Reader, big.NewInt(int64(slowdownJitter))) + if err != nil { + return + } + + // and wait that long + time.Sleep(time.Duration(delay.Int64())) + }() + + return f() + +} diff --git a/internal/dis/component/ssh2/sshkeys/table.go b/internal/dis/component/ssh2/sshkeys/table.go new file mode 100644 index 0000000..4bc9975 --- /dev/null +++ b/internal/dis/component/ssh2/sshkeys/table.go @@ -0,0 +1,128 @@ +package sshkeys + +import ( + "context" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/models" + "github.com/gliderlabs/ssh" + "github.com/tkw1536/goprogram/lib/reflectx" +) + +func (ssh2 *SSHKeys) TableInfo() component.TableInfo { + return component.TableInfo{ + Model: reflectx.TypeOf[models.Keys](), + Name: models.KeysTable, + } +} + +// Keys returns a list of keys for the given user +func (ssh2 *SSHKeys) Keys(ctx context.Context, user string) ([]models.Keys, error) { + // the empty user has no key + if user == "" { + return nil, nil + } + + // get the table + table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2) + if err != nil { + return nil, err + } + + var keys []models.Keys + + // make a query to find all keys (in the underlying model) + query := table.Find(&keys, &models.Keys{User: user}) + if query.Error != nil { + return nil, query.Error + } + + return keys, nil +} + +// Add adds a new key to the given user, unless it already exists +func (ssh2 *SSHKeys) Add(ctx context.Context, user string, comment string, key ssh.PublicKey) error { + // check that the given user exists + { + _, err := ssh2.Dependencies.Auth.User(ctx, user) + if err != nil { + return err + } + } + + // fetch all the keys + keys, err := ssh2.Keys(ctx, user) + if err != nil { + return err + } + + pks := make([]ssh.PublicKey, 0, len(keys)) + for _, key := range keys { + if pk := key.PublicKey(); pk != nil { + pks = append(pks, pk) + } + } + + // key already exists + if KeyOneOf(pks, key) { + return nil + } + + // create a new key with the given comment + mk := models.Keys{ + User: user, + Comment: comment, + } + mk.SetPublicKey(key) + + // get the table + table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2) + if err != nil { + return err + } + + // create the key instance + return table.Create(&mk).Error +} + +// Remove removes a given publuc key from a user. +func (ssh2 *SSHKeys) Remove(ctx context.Context, user string, key ssh.PublicKey) error { + // find all the keys for the given user + keys, err := ssh2.Keys(ctx, user) + if err != nil { + return err + } + + // iterate and find all the public keys + var pks []uint + for _, candidate := range keys { + if ssh.KeysEqual(candidate.PublicKey(), key) { + pks = append(pks, candidate.Pk) + } + } + + // nothing to delete + if len(pks) == 0 { + return nil + } + + // query the table again + table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2) + if err != nil { + return nil + } + + // and do the delete + return table.Where("pk in ?", pks).Delete(&models.Keys{}).Error +} + +func (ssh2 *SSHKeys) OnUserDelete(ctx context.Context, user *models.User) error { + // get the table + table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2) + if err != nil { + return err + } + + // delete all keys for the user + return table.Delete(&models.Keys{}, &models.Keys{User: user.User}).Error +} diff --git a/internal/dis/component/ssh2/stack.go b/internal/dis/component/ssh2/stack.go index 532cf90..8692d52 100644 --- a/internal/dis/component/ssh2/stack.go +++ b/internal/dis/component/ssh2/stack.go @@ -31,9 +31,8 @@ func (ssh *SSH2) Stack(env environment.Environment) component.StackWithResources "CONFIG_PATH": ssh.Config.ConfigPath, "DEPLOY_ROOT": ssh.Config.DeployRoot, - "GLOBAL_AUTHORIZED_KEYS_FILE": ssh.Config.GlobalAuthorizedKeysFile, - "SELF_OVERRIDES_FILE": ssh.Config.SelfOverridesFile, - "SELF_RESOLVER_BLOCK_FILE": ssh.Config.SelfResolverBlockFile, + "SELF_OVERRIDES_FILE": ssh.Config.SelfOverridesFile, + "SELF_RESOLVER_BLOCK_FILE": ssh.Config.SelfResolverBlockFile, "SSH_PORT": strconv.FormatUint(uint64(ssh.Config.PublicSSHPort), 10), }, diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index 3125fed..ae431c4 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -28,6 +28,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/solr" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/triplestore" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/web" "github.com/FAU-CDI/wisski-distillery/pkg/lazy" @@ -81,6 +82,9 @@ func (dis *Distillery) SSH() *ssh2.SSH2 { func (dis *Distillery) Auth() *auth.Auth { return export[*auth.Auth](dis) } +func (dis *Distillery) Keys() *sshkeys.SSHKeys { + return export[*sshkeys.SSHKeys](dis) +} func (dis *Distillery) Cron() *cron.Cron { return export[*cron.Cron](dis) @@ -162,6 +166,7 @@ func (dis *Distillery) allComponents() []initFunc { // ssh server auto[*ssh2.SSH2], + auto[*sshkeys.SSHKeys], // Control server auto[*control.Control], diff --git a/internal/models/keys.go b/internal/models/keys.go new file mode 100644 index 0000000..ccd86f9 --- /dev/null +++ b/internal/models/keys.go @@ -0,0 +1,45 @@ +package models + +import ( + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" +) + +// KeysTable is the name of the table the [Keys] model is stored in. +const KeysTable = "keys" + +// Keys represents a distillery ssh key +type Keys struct { + Pk uint `gorm:"column:pk;primaryKey"` + + User string `gorm:"column:user;not null"` // username of the ssh key + + Signature []byte `gorm:"column:signature;not null"` // signature of the ssh key + Comment string `gorm:"column:comment"` +} + +// PublicKey returns the public key corresponding to this keys. +// If the key cannot be parsed, returns nil. +func (keys *Keys) PublicKey() ssh.PublicKey { + key, err := ssh.ParsePublicKey(keys.Signature) + if err != nil { + return nil + } + return key +} + +func (keys *Keys) SignatureString() string { + // try to get the public key + key := keys.PublicKey() + if key == nil { + return "" + } + + // marshal the key! + return string(gossh.MarshalAuthorizedKey(key)) +} + +// SetPublicKey stores a specific public key in this key +func (keys *Keys) SetPublicKey(key ssh.PublicKey) { + keys.Signature = key.Marshal() +} diff --git a/internal/status/wisski.go b/internal/status/wisski.go index ac2415c..c6acbe4 100644 --- a/internal/status/wisski.go +++ b/internal/status/wisski.go @@ -29,7 +29,7 @@ type WissKI struct { // List of backups made Snapshots []models.Export - // List of SSH Keys + // List of SSH Keys that have access to this server SSHKeys []string // WissKI content information diff --git a/internal/wisski/ingredient/barrel/barrel.env b/internal/wisski/ingredient/barrel/barrel.env index fbc862e..791e9ac 100644 --- a/internal/wisski/ingredient/barrel/barrel.env +++ b/internal/wisski/ingredient/barrel/barrel.env @@ -1,6 +1,5 @@ DATA_PATH=${DATA_PATH} RUNTIME_DIR=${RUNTIME_DIR} -GLOBAL_AUTHORIZED_KEYS_FILE=${GLOBAL_AUTHORIZED_KEYS_FILE} SLUG=${SLUG} VIRTUAL_HOST=${VIRTUAL_HOST} diff --git a/internal/wisski/ingredient/barrel/barrel.go b/internal/wisski/ingredient/barrel/barrel.go index 7f9bae6..ec81d31 100644 --- a/internal/wisski/ingredient/barrel/barrel.go +++ b/internal/wisski/ingredient/barrel/barrel.go @@ -20,7 +20,3 @@ type Barrel struct { func (barrel *Barrel) DataPath() string { return filepath.Join(barrel.FilesystemBase, "data") } - -func (barrel *Barrel) AuthorizedKeysPath() string { - return filepath.Join(barrel.DataPath(), "authorized_keys") -} diff --git a/internal/wisski/ingredient/barrel/barrel/docker-compose.yml b/internal/wisski/ingredient/barrel/barrel/docker-compose.yml index 466ee8f..781235e 100644 --- a/internal/wisski/ingredient/barrel/barrel/docker-compose.yml +++ b/internal/wisski/ingredient/barrel/barrel/docker-compose.yml @@ -8,9 +8,6 @@ services: # label it with the current slug labels: - - "eu.wiss-ki.barrel.slug=${SLUG}" - - "eu.wiss-ki.barrel.authfile=/var/www/.ssh/authorized_keys,/var/www/.ssh/global_authorized_keys" - - "traefik.enable=True" - "eu.wiss-ki.barrel.distillery=${DOCKER_NETWORK_NAME}" @@ -21,12 +18,10 @@ services: # volumes that are mounted volumes: - - ${GLOBAL_AUTHORIZED_KEYS_FILE}:/var/www/.ssh/global_authorized_keys:ro - ${DATA_PATH}/.composer:/var/www/.composer - ${DATA_PATH}/data:/var/www/data - ${DATA_PATH}/home:/var/www/ - ${DATA_PATH}/hostkeys:/ssh/hostkeys:rw - - ${DATA_PATH}/authorized_keys:/var/www/.ssh/authorized_keys - ${RUNTIME_DIR}:/runtime:ro networks: diff --git a/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh b/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh index 80c4775..5f95d13 100644 --- a/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh +++ b/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh @@ -1,3 +1,3 @@ #!/bin/bash -cat /var/www/.ssh/authorized_keys /var/www/.ssh/global_authorized_keys 2> /dev/null || exit 0 +curl -H "Host:$(hostname -f)" http://dis:9999/authorized_keys diff --git a/internal/wisski/ingredient/barrel/ssh/ssh.go b/internal/wisski/ingredient/barrel/ssh/ssh.go index 3be19e8..e887fb9 100644 --- a/internal/wisski/ingredient/barrel/ssh/ssh.go +++ b/internal/wisski/ingredient/barrel/ssh/ssh.go @@ -1,13 +1,11 @@ package ssh import ( - "io" + "context" "github.com/FAU-CDI/wisski-distillery/internal/status" "github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient" "github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/barrel" - "github.com/FAU-CDI/wisski-distillery/pkg/environment" - "github.com/FAU-CDI/wisski-distillery/pkg/sshx" "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" ) @@ -23,28 +21,57 @@ var ( _ ingredient.WissKIFetcher = (*SSH)(nil) ) -func (ssh *SSH) Keys() ([]ssh.PublicKey, error) { - file, err := ssh.Environment.Open(ssh.Dependencies.Barrel.AuthorizedKeysPath()) - if environment.IsNotExist(err) { - return nil, nil - } +func (ssh *SSH) Keys(ctx context.Context) (keys []ssh.PublicKey, err error) { + grants, err := ssh.Liquid.Policy.Instance(ctx, ssh.Slug) if err != nil { return nil, err } - bytes, err := io.ReadAll(file) - if err != nil { - return nil, err + // iterate over enabled distillery admin users + for _, grant := range grants { + if !grant.DrupalAdminRole { + continue + } + ukeys, err := ssh.Liquid.Keys.Keys(ctx, grant.User) + if err != nil { + return nil, err + } + for _, ukey := range ukeys { + if pk := ukey.PublicKey(); pk != nil { + keys = append(keys, pk) + } + } } - return sshx.ParseAllKeys(bytes), nil + + // and return the keys! + return keys, nil } -func (sshx *SSH) Fetch(flags ingredient.FetcherFlags, info *status.WissKI) error { +// AllKeys returns the keys specifically registered to this instance and all the globally registered keys. +func (ssh *SSH) AllKeys(ctx context.Context) (keys []ssh.PublicKey, err error) { + lkeys, err := ssh.Keys(ctx) + if err != nil { + return nil, err + } + + gkeys, err := ssh.Liquid.Keys.Admin(ctx) + if err != nil { + return nil, err + } + + keys = append(keys, lkeys...) + keys = append(keys, gkeys...) + + return keys, nil +} + +func (ssh *SSH) Fetch(flags ingredient.FetcherFlags, info *status.WissKI) error { if flags.Quick { return nil } - keys, err := sshx.Keys() + // add the instance keys + keys, err := ssh.AllKeys(flags.Context) if err != nil { return err } @@ -53,5 +80,6 @@ func (sshx *SSH) Fetch(flags ingredient.FetcherFlags, info *status.WissKI) error for i, key := range keys { info.SSHKeys[i] = string(gossh.MarshalAuthorizedKey(key)) } + return nil } diff --git a/internal/wisski/ingredient/barrel/stack.go b/internal/wisski/ingredient/barrel/stack.go index 596e23b..a2714c4 100644 --- a/internal/wisski/ingredient/barrel/stack.go +++ b/internal/wisski/ingredient/barrel/stack.go @@ -29,15 +29,10 @@ func (barrel *Barrel) Stack() component.StackWithResources { "VIRTUAL_HOST": barrel.Domain(), "HTTPS_ENABLED": barrel.Malt.Config.HTTPSEnabledEnv(), - "DATA_PATH": filepath.Join(barrel.FilesystemBase, "data"), - "RUNTIME_DIR": barrel.Malt.Config.RuntimeDir(), - "GLOBAL_AUTHORIZED_KEYS_FILE": barrel.Malt.Config.GlobalAuthorizedKeysFile, + "DATA_PATH": filepath.Join(barrel.FilesystemBase, "data"), + "RUNTIME_DIR": barrel.Malt.Config.RuntimeDir(), }, MakeDirs: []string{"data", ".composer"}, - - TouchFiles: []string{ - filepath.Join("data", "authorized_keys"), - }, } } diff --git a/internal/wisski/liquid/domain.go b/internal/wisski/liquid/domain.go index b5663b8..0fbc1b7 100644 --- a/internal/wisski/liquid/domain.go +++ b/internal/wisski/liquid/domain.go @@ -1,13 +1,12 @@ package liquid import ( - "fmt" "net/url" ) // Domain returns the full domain name of this WissKI func (liquid *Liquid) Domain() string { - return fmt.Sprintf("%s.%s", liquid.Slug, liquid.Malt.Config.DefaultDomain) + return liquid.Config.HostFromSlug(liquid.Slug) } // URL returns the public URL of this instance diff --git a/pkg/httpx/field/field.go b/pkg/httpx/field/field.go index 061c7d4..50615bf 100644 --- a/pkg/httpx/field/field.go +++ b/pkg/httpx/field/field.go @@ -6,9 +6,23 @@ import ( ) // DefaultFieldTemplate is the default template to render fields. -var DefaultFieldTemplate = template.Must(template.New("").Parse(`{{.Value}} +{{ else }} +`)) + +
+ +{{ if (eq .Type "textarea" )}} + +{{ else }} + +{{ end }} +
`)) // Field represents a field inside a form. type Field struct { diff --git a/pkg/httpx/field/type.go b/pkg/httpx/field/type.go index ca2347f..c056854 100644 --- a/pkg/httpx/field/type.go +++ b/pkg/httpx/field/type.go @@ -27,4 +27,6 @@ const ( Url InputType = "url" Week InputType = "week" Datetime InputType = "datetime" + + Textarea InputType = "textarea" // special ) diff --git a/pkg/httpx/wrap.go b/pkg/httpx/wrap.go new file mode 100644 index 0000000..0c082d4 --- /dev/null +++ b/pkg/httpx/wrap.go @@ -0,0 +1,14 @@ +package httpx + +import ( + "context" + "net/http" +) + +// WithContextWrapper generates a new handler that wraps the context of each request with the wrapper function. +func WithContextWrapper(handler http.Handler, wrapper func(context.Context) context.Context) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(wrapper(r.Context())) + handler.ServeHTTP(w, r) + }) +}