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.
+
+ 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:
+