diff --git a/cmd/keyctl/main.go b/cmd/keyctl/main.go index fbbfb5f..187ca19 100644 --- a/cmd/keyctl/main.go +++ b/cmd/keyctl/main.go @@ -17,6 +17,7 @@ import ( "git.giftfish.de/ston1th/keyctl/pkg/api" "git.giftfish.de/ston1th/keyctl/pkg/cli" "git.giftfish.de/ston1th/keyctl/pkg/db" + "git.giftfish.de/ston1th/keyctl/pkg/ssh" "github.com/go-logr/logr" ) @@ -41,6 +42,10 @@ func usage() { keyctl approve - approve a key request (admin) keyctl reject - reject a key request (admin) keyctl del - delete a key (admin) + keyctl ssh login - request ssh access + keyctl ssh req - list current ssh requests (admin) + keyctl ssh approve - approve ssh access (admin) + keyctl ssh reject - reject ssh access (admin) keyctl help - show this usage `, version) os.Exit(1) @@ -56,6 +61,11 @@ func main() { cli.Run(arg) // does not return case "server": server() // does not return + case "ssh": + if len(os.Args) < 3 { + usage() // does not return + } + ssh.Run(os.Args[2]) // does not return default: usage() // does not return } diff --git a/pkg/api/client/client.go b/pkg/api/client/client.go index 36dc483..6003227 100644 --- a/pkg/api/client/client.go +++ b/pkg/api/client/client.go @@ -264,7 +264,9 @@ func (c *Client) FollowRedirectLoop(r *http.Request, v any, delay time.Duration) rdr, ok := err.(Redirect) if err != nil && !ok { if errors.Is(err, types.ErrISE) || - errors.Is(err, types.ErrKeyNotFound) { + errors.Is(err, types.ErrKeyNotFound) || + errors.Is(err, types.ErrAccessReqNotFound) || + errors.Is(err, types.ErrAccessRequestRejected) { return } if errors.Is(err, types.ErrReqNotFound) || diff --git a/pkg/api/server.go b/pkg/api/server.go index ed85fa1..9a90f66 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -48,6 +48,7 @@ func NewServer(log logr.Logger, listen, socket string, db *db.DB) (*Server, erro log: log, data: &types.ContextData{ Approver: types.NewApprover(), + SSH: types.NewSSH(), DB: db, }, srv: &http.Server{ diff --git a/pkg/api/types/context.go b/pkg/api/types/context.go index a272ebc..93de796 100644 --- a/pkg/api/types/context.go +++ b/pkg/api/types/context.go @@ -14,6 +14,7 @@ import ( type ContextData struct { Approver *Approver + SSH *SSH DB *db.DB } @@ -97,6 +98,10 @@ func (c *Context) Approver() *Approver { return c.data.Approver } +func (c *Context) SSH() *SSH { + return c.data.SSH +} + // OK is a empty HTTP 200 JSON response func (c *Context) OK() { c.log() diff --git a/pkg/api/types/error.go b/pkg/api/types/error.go index df249b1..81f9805 100644 --- a/pkg/api/types/error.go +++ b/pkg/api/types/error.go @@ -43,15 +43,17 @@ func newError(err string, code int) Error { } var ( - ErrInvalidAPIRoute = newError("invalid api route", http.StatusNotFound) - ErrInvalid = newError("invalid data", http.StatusBadRequest) - ErrBadreq = newError("bad request", http.StatusBadRequest) - ErrForbidden = newError("forbidden", http.StatusForbidden) - ErrNotFound = newError("not found", http.StatusNotFound) - ErrKeyNotFound = newError("key not found", http.StatusNotFound) - ErrReqNotFound = newError("key request not found", http.StatusNotFound) - ErrTooManyApprovals = newError("too many approvals pending", http.StatusConflict) - ErrKeyRequestRejected = newError("key request rejected", http.StatusForbidden) - ErrMNA = newError("method not allowed", http.StatusMethodNotAllowed) - ErrISE = newError("internal server error", http.StatusInternalServerError) + ErrInvalidAPIRoute = newError("invalid api route", http.StatusNotFound) + ErrInvalid = newError("invalid data", http.StatusBadRequest) + ErrBadreq = newError("bad request", http.StatusBadRequest) + ErrForbidden = newError("forbidden", http.StatusForbidden) + ErrNotFound = newError("not found", http.StatusNotFound) + ErrKeyNotFound = newError("key not found", http.StatusNotFound) + ErrReqNotFound = newError("key request not found", http.StatusNotFound) + ErrAccessReqNotFound = newError("ssh request not found", http.StatusNotFound) + ErrTooManyApprovals = newError("too many approvals pending", http.StatusConflict) + ErrKeyRequestRejected = newError("key request rejected", http.StatusForbidden) + ErrAccessRequestRejected = newError("ssh request rejected", http.StatusForbidden) + ErrMNA = newError("method not allowed", http.StatusMethodNotAllowed) + ErrISE = newError("internal server error", http.StatusInternalServerError) ) diff --git a/pkg/api/types/ssh.go b/pkg/api/types/ssh.go new file mode 100644 index 0000000..74bdfba --- /dev/null +++ b/pkg/api/types/ssh.go @@ -0,0 +1,99 @@ +// Copyright (C) 2023 Marius Schellenberger + +package types + +import ( + "maps" + "sync" + "time" + + "git.giftfish.de/ston1th/keyctl/pkg/key" +) + +type SSHMap map[string]SSHAccess + +type SSH struct { + // protects m + mu sync.RWMutex + + m SSHMap +} + +func NewSSH() *SSH { + return &SSH{ + m: make(SSHMap), + } +} + +func (s *SSH) New(host, user, ip string) (aid string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + size := len(s.m) + if size > maxSize { + err = ErrTooManyApprovals + return + } + aid, err = key.GenerateID() + if err != nil { + return + } + s.m[aid] = SSHAccess{ + Host: host, + User: user, + IP: ip, + } + return +} + +func (s *SSH) List() (m SSHMap) { + s.mu.RLock() + m = maps.Clone(s.m) + s.mu.RUnlock() + return +} +func (s *SSH) Status(aid, host, user, ip string) (status Status) { + s.mu.RLock() + if app, ok := s.m[aid]; ok { + if app.Host == host && app.IP == ip && app.User == user { + status = app.Status + } else { + status = Rejected + } + } else { + status = NotFound + } + s.mu.RUnlock() + if status == Approved || status == Rejected { + s.mu.Lock() + delete(s.m, aid) + s.mu.Unlock() + } + return +} + +func (s *SSH) Update(aid string, status Status) (err error) { + s.mu.Lock() + if app, ok := s.m[aid]; ok { + app.Status = status + s.m[aid] = app + go s.cleanup(aid) + } else { + err = ErrAccessReqNotFound + } + s.mu.Unlock() + return +} + +func (s *SSH) cleanup(aid string) { + time.Sleep(time.Second * 10) + s.mu.Lock() + delete(s.m, aid) + s.mu.Unlock() +} + +type SSHAccess struct { + Host string + User string + IP string + Status Status +} diff --git a/pkg/api/v1/client/client.go b/pkg/api/v1/client/client.go index f96994f..5135c55 100644 --- a/pkg/api/v1/client/client.go +++ b/pkg/api/v1/client/client.go @@ -18,6 +18,7 @@ var ( ErrClientIsNil = errors.New("client is nil") keyPath = schema.KeyPath reqPath = schema.ReqPath + accessPath = schema.AccessPath ) type Client struct { @@ -121,3 +122,45 @@ func (c *Client) DeleteKey(ctx context.Context, id string) (err error) { _, err = c.c.Do(req, nil) return } + +func (c *Client) Access(ctx context.Context, host, user string) (ok bool, err error) { + path := fmt.Sprintf("%s/%s/%s", accessPath, host, user) + req, err := c.c.NewRequest(ctx, "GET", path, nil) + if err != nil { + return + } + _, err = c.c.FollowRedirectLoop(req, &ok, time.Second*2) + return +} + +func (c *Client) GetAccess(ctx context.Context) (accessList schema.AccessList, err error) { + path := accessPath + req, err := c.c.NewRequest(ctx, "GET", path, nil) + if err != nil { + return + } + _, err = c.c.Do(req, &accessList) + return +} + +func (c *Client) RejectAccess(ctx context.Context, id string) error { + return c.sendAccess(ctx, id, false) +} + +func (c *Client) AcceptAccess(ctx context.Context, id string) error { + return c.sendAccess(ctx, id, true) +} + +func (c *Client) sendAccess(ctx context.Context, id string, approve bool) (err error) { + path := fmt.Sprintf("%s/%s", accessPath, id) + method := "DELETE" + if approve { + method = "PUT" + } + req, err := c.c.NewRequest(ctx, method, path, nil) + if err != nil { + return + } + _, err = c.c.Do(req, nil) + return +} diff --git a/pkg/api/v1/schema/schema.go b/pkg/api/v1/schema/schema.go index 3dd3466..f5d304d 100644 --- a/pkg/api/v1/schema/schema.go +++ b/pkg/api/v1/schema/schema.go @@ -14,11 +14,13 @@ import ( ) const ( - Version = "v1" - KeyName = "key" - ReqName = "req" - KeyPath = "/" + Version + "/" + KeyName - ReqPath = "/" + Version + "/" + ReqName + Version = "v1" + KeyName = "key" + ReqName = "req" + KeyPath = "/" + Version + "/" + KeyName + ReqPath = "/" + Version + "/" + ReqName + SSH = "ssh" + AccessPath = "/" + Version + "/" + SSH ) type NewKey struct { @@ -68,6 +70,10 @@ func ApproveURL(id, aid string) string { return KeyPath + "/" + id + "/" + aid } +func AccessURL(host, user, aid string) string { + return AccessPath + "/" + host + "/" + user + "/" + aid +} + type Key struct { ID string `json:"id"` Name string `json:"name"` @@ -170,3 +176,47 @@ func NewApprovals(m types.ApprovalMap) (a Approvals) { a.Sort() return } + +type Access struct { + Host string `json:"host"` + User string `json:"user"` + AID string `json:"aid"` + IP string `json:"ip"` + Status types.Status `json:"status"` +} + +type AccessList []Access + +func (AccessList) Less(a, b Access) int { return cmp.Compare(a.Host, b.Host) } +func (a AccessList) Sort() { slices.SortStableFunc(a, a.Less) } +func (a AccessList) Len() int { return len(a) } +func (a AccessList) Fields() string { + return "Host\t User\t Approval ID\t IP\t Status" +} +func (a AccessList) Values() []string { + vals := make([]string, len(a)) + for i, v := range a { + vals[i] = fmt.Sprintf("%s\t %s\t %s\t %s\t %s", + v.Host, + v.User, + v.AID, + v.IP, + v.Status, + ) + } + return vals +} + +func NewAccessList(m types.SSHMap) (a AccessList) { + for k, v := range m { + a = append(a, Access{ + Host: v.Host, + User: v.User, + AID: k, + IP: v.IP, + Status: v.Status, + }) + } + a.Sort() + return +} diff --git a/pkg/api/v1/server/handler.go b/pkg/api/v1/server/handler.go index fe8b0be..6c93482 100644 --- a/pkg/api/v1/server/handler.go +++ b/pkg/api/v1/server/handler.go @@ -157,3 +157,57 @@ func reqHandler(ctx *types.Context) { } ctx.OK() } + +func accessHandler(ctx *types.Context) { + host := ctx.Var("host") + user := ctx.Var("user") + ip := ctx.ClientIP() + ssh := ctx.SSH() + aid := ctx.Var("aid") + var err error + if aid == "" { + aid, err = ssh.New(host, user, ip) + if err != nil { + ctx.Log.Error(err, "error creating access", "host", host, "user", user) + ctx.Err(types.ErrTooManyApprovals) + return + } + ctx.Redirect(schema.AccessURL(host, user, aid), http.StatusFound) + return + } + s := ssh.Status(aid, host, user, ip) + switch s { + case types.Pending: + ctx.Redirect(schema.AccessURL(host, user, aid), http.StatusFound) + return + case types.Rejected: + ctx.Err(types.ErrAccessRequestRejected) + return + case types.NotFound: + ctx.Err(types.ErrAccessReqNotFound) + return + } + ctx.JSON(true) +} +func accessReqHandler(ctx *types.Context) { + id := ctx.Var("id") + status := types.Pending + switch ctx.Method() { + case "PUT": + status = types.Approved + case "DELETE": + status = types.Rejected + } + err := ctx.SSH().Update(id, status) + if err != nil { + ctx.Log.Error(err, "error updating ssh request") + ctx.Err(types.ErrAccessReqNotFound) + return + } + ctx.OK() +} + +func accessListHandler(ctx *types.Context) { + l := ctx.SSH().List() + ctx.JSON(schema.NewAccessList(l)) +} diff --git a/pkg/api/v1/server/routes.go b/pkg/api/v1/server/routes.go index 89b58b8..a3d2368 100644 --- a/pkg/api/v1/server/routes.go +++ b/pkg/api/v1/server/routes.go @@ -9,9 +9,12 @@ import ( ) const ( - keyID = schema.KeyPath + core.IDRoute - keyReqID = schema.KeyPath + core.IDRoute + core.AIDRoute - reqID = schema.ReqPath + core.IDRoute + keyID = schema.KeyPath + core.IDRoute + keyReqID = schema.KeyPath + core.IDRoute + core.AIDRoute + reqID = schema.ReqPath + core.IDRoute + accessID = schema.AccessPath + core.IDRoute + access = schema.AccessPath + core.HostRoute + core.UserRoute + accessAID = schema.AccessPath + core.HostRoute + core.UserRoute + core.AIDRoute ) var Routes = []types.Route{ @@ -50,4 +53,25 @@ var Routes = []types.Route{ Handler: local(reqHandler), Methods: []string{"PUT", "DELETE"}, }, + // SSH + { + Path: accessID, + Handler: local(accessReqHandler), + Methods: []string{"PUT", "DELETE"}, + }, + { + Path: schema.AccessPath, + Handler: local(accessListHandler), + Methods: []string{"GET"}, + }, + { + Path: access, + Handler: accessHandler, + Methods: []string{"GET"}, + }, + { + Path: accessAID, + Handler: accessHandler, + Methods: []string{"GET"}, + }, } diff --git a/pkg/core/key.go b/pkg/core/key.go index 1180358..a1d74bb 100644 --- a/pkg/core/key.go +++ b/pkg/core/key.go @@ -9,6 +9,8 @@ const ( nameReReverse = "[^" + re + "]+" IDRoute = "/{id}" AIDRoute = "/{aid}" + UserRoute = "/{user}" + HostRoute = "/{host}" ) var NameRe = regexp.MustCompile(nameReReverse) diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go new file mode 100644 index 0000000..6ffbd43 --- /dev/null +++ b/pkg/ssh/ssh.go @@ -0,0 +1,134 @@ +// Copyright (C) 2023 Marius Schellenberger + +package ssh + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "text/tabwriter" + + apiclient "git.giftfish.de/ston1th/keyctl/pkg/api/client" + clientv1 "git.giftfish.de/ston1th/keyctl/pkg/api/v1/client" + "git.giftfish.de/ston1th/keyctl/pkg/core" + "golang.org/x/sys/unix" +) + +var fs = flag.NewFlagSet("", flag.ExitOnError) + +func exit(err error) { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) +} + +func Run(arg string) { + ctx := context.Background() + switch arg { + case "approve": + id, err := idFlags("access id") + if err != nil { + exit(err) + } + c := client() + err = c.AcceptAccess(ctx, id) + if err != nil { + exit(err) + } + case "reject": + id, err := idFlags("access id") + if err != nil { + exit(err) + } + c := client() + err = c.RejectAccess(ctx, id) + if err != nil { + exit(err) + } + case "login": + endpoint, err := loginFlags() + if err != nil { + exit(err) + } + opts, err := apiclient.ClientOptionsFromConfig( + apiclient.Config{Endpoint: endpoint}, + ) + if err != nil { + exit(err) + } + c := client(opts...) + host, err := os.Hostname() + if err != nil { + exit(err) + } + // TODO: fix me (user) + ok, err := c.Access(ctx, host, "user") + if err != nil { + exit(err) + } + if ok { + unix.Exec("/bin/bash", []string{"bash"}, os.Environ()) // does not return + } + case "req": + c := client() + access, err := c.GetAccess(ctx) + if err != nil { + exit(err) + } + print(access) + } + os.Exit(0) +} + +func client(opts ...apiclient.ClientOption) (c *clientv1.Client) { + var err error + if opts == nil { + opts, err = apiclient.ClientOptionsFromEnv() + if err != nil { + exit(err) + } + } + apic := apiclient.NewClient(opts...) + //if socket, ok := apic.IsUnix(); ok { + // err = unveil(socket) + // if err != nil { + // exit(err) + // } + //} + c, err = clientv1.NewClient(apic) + if err != nil { + exit(err) + } + return +} + +func print(tw core.TableWriter) { + if tw.Len() == 0 { + return + } + w := tabwriter.NewWriter(os.Stdout, 1, 0, 1, ' ', tabwriter.Debug) + fmt.Fprintln(w, tw.Fields()) + for _, v := range tw.Values() { + fmt.Fprintln(w, v) + } + w.Flush() +} + +func idFlags(desc string) (id string, err error) { + fs.StringVar(&id, "id", "", desc) + fs.Parse(os.Args[3:]) + if id == "" { + err = errors.New("missing flag: id") + } + return +} + +func loginFlags() (endpoint string, err error) { + fs.StringVar(&endpoint, "endpoint", "", "keyctl endpoint") + fs.Parse(os.Args[3:]) + if endpoint == "" { + err = errors.New("missing flag: endpoint") + } + return +}