implemented simple ssh dual control

This commit is contained in:
ston1th 2025-01-23 00:15:25 +01:00
commit 60012be942
12 changed files with 446 additions and 20 deletions

View file

@ -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) ||

View file

@ -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{

View file

@ -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()

View file

@ -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)
)

99
pkg/api/types/ssh.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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"},
},
}