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

@ -17,6 +17,7 @@ import (
"git.giftfish.de/ston1th/keyctl/pkg/api" "git.giftfish.de/ston1th/keyctl/pkg/api"
"git.giftfish.de/ston1th/keyctl/pkg/cli" "git.giftfish.de/ston1th/keyctl/pkg/cli"
"git.giftfish.de/ston1th/keyctl/pkg/db" "git.giftfish.de/ston1th/keyctl/pkg/db"
"git.giftfish.de/ston1th/keyctl/pkg/ssh"
"github.com/go-logr/logr" "github.com/go-logr/logr"
) )
@ -41,6 +42,10 @@ func usage() {
keyctl approve - approve a key request (admin) keyctl approve - approve a key request (admin)
keyctl reject - reject a key request (admin) keyctl reject - reject a key request (admin)
keyctl del - delete a key (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 keyctl help - show this usage
`, version) `, version)
os.Exit(1) os.Exit(1)
@ -56,6 +61,11 @@ func main() {
cli.Run(arg) // does not return cli.Run(arg) // does not return
case "server": case "server":
server() // does not return server() // does not return
case "ssh":
if len(os.Args) < 3 {
usage() // does not return
}
ssh.Run(os.Args[2]) // does not return
default: default:
usage() // does not return usage() // does not return
} }

View file

@ -264,7 +264,9 @@ func (c *Client) FollowRedirectLoop(r *http.Request, v any, delay time.Duration)
rdr, ok := err.(Redirect) rdr, ok := err.(Redirect)
if err != nil && !ok { if err != nil && !ok {
if errors.Is(err, types.ErrISE) || 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 return
} }
if errors.Is(err, types.ErrReqNotFound) || 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, log: log,
data: &types.ContextData{ data: &types.ContextData{
Approver: types.NewApprover(), Approver: types.NewApprover(),
SSH: types.NewSSH(),
DB: db, DB: db,
}, },
srv: &http.Server{ srv: &http.Server{

View file

@ -14,6 +14,7 @@ import (
type ContextData struct { type ContextData struct {
Approver *Approver Approver *Approver
SSH *SSH
DB *db.DB DB *db.DB
} }
@ -97,6 +98,10 @@ func (c *Context) Approver() *Approver {
return c.data.Approver return c.data.Approver
} }
func (c *Context) SSH() *SSH {
return c.data.SSH
}
// OK is a empty HTTP 200 JSON response // OK is a empty HTTP 200 JSON response
func (c *Context) OK() { func (c *Context) OK() {
c.log() c.log()

View file

@ -43,15 +43,17 @@ func newError(err string, code int) Error {
} }
var ( var (
ErrInvalidAPIRoute = newError("invalid api route", http.StatusNotFound) ErrInvalidAPIRoute = newError("invalid api route", http.StatusNotFound)
ErrInvalid = newError("invalid data", http.StatusBadRequest) ErrInvalid = newError("invalid data", http.StatusBadRequest)
ErrBadreq = newError("bad request", http.StatusBadRequest) ErrBadreq = newError("bad request", http.StatusBadRequest)
ErrForbidden = newError("forbidden", http.StatusForbidden) ErrForbidden = newError("forbidden", http.StatusForbidden)
ErrNotFound = newError("not found", http.StatusNotFound) ErrNotFound = newError("not found", http.StatusNotFound)
ErrKeyNotFound = newError("key not found", http.StatusNotFound) ErrKeyNotFound = newError("key not found", http.StatusNotFound)
ErrReqNotFound = newError("key request not found", http.StatusNotFound) ErrReqNotFound = newError("key request not found", http.StatusNotFound)
ErrTooManyApprovals = newError("too many approvals pending", http.StatusConflict) ErrAccessReqNotFound = newError("ssh request not found", http.StatusNotFound)
ErrKeyRequestRejected = newError("key request rejected", http.StatusForbidden) ErrTooManyApprovals = newError("too many approvals pending", http.StatusConflict)
ErrMNA = newError("method not allowed", http.StatusMethodNotAllowed) ErrKeyRequestRejected = newError("key request rejected", http.StatusForbidden)
ErrISE = newError("internal server error", http.StatusInternalServerError) 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") ErrClientIsNil = errors.New("client is nil")
keyPath = schema.KeyPath keyPath = schema.KeyPath
reqPath = schema.ReqPath reqPath = schema.ReqPath
accessPath = schema.AccessPath
) )
type Client struct { type Client struct {
@ -121,3 +122,45 @@ func (c *Client) DeleteKey(ctx context.Context, id string) (err error) {
_, err = c.c.Do(req, nil) _, err = c.c.Do(req, nil)
return 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 ( const (
Version = "v1" Version = "v1"
KeyName = "key" KeyName = "key"
ReqName = "req" ReqName = "req"
KeyPath = "/" + Version + "/" + KeyName KeyPath = "/" + Version + "/" + KeyName
ReqPath = "/" + Version + "/" + ReqName ReqPath = "/" + Version + "/" + ReqName
SSH = "ssh"
AccessPath = "/" + Version + "/" + SSH
) )
type NewKey struct { type NewKey struct {
@ -68,6 +70,10 @@ func ApproveURL(id, aid string) string {
return KeyPath + "/" + id + "/" + aid return KeyPath + "/" + id + "/" + aid
} }
func AccessURL(host, user, aid string) string {
return AccessPath + "/" + host + "/" + user + "/" + aid
}
type Key struct { type Key struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -170,3 +176,47 @@ func NewApprovals(m types.ApprovalMap) (a Approvals) {
a.Sort() a.Sort()
return 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() 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 ( const (
keyID = schema.KeyPath + core.IDRoute keyID = schema.KeyPath + core.IDRoute
keyReqID = schema.KeyPath + core.IDRoute + core.AIDRoute keyReqID = schema.KeyPath + core.IDRoute + core.AIDRoute
reqID = schema.ReqPath + core.IDRoute 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{ var Routes = []types.Route{
@ -50,4 +53,25 @@ var Routes = []types.Route{
Handler: local(reqHandler), Handler: local(reqHandler),
Methods: []string{"PUT", "DELETE"}, 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"},
},
} }

View file

@ -9,6 +9,8 @@ const (
nameReReverse = "[^" + re + "]+" nameReReverse = "[^" + re + "]+"
IDRoute = "/{id}" IDRoute = "/{id}"
AIDRoute = "/{aid}" AIDRoute = "/{aid}"
UserRoute = "/{user}"
HostRoute = "/{host}"
) )
var NameRe = regexp.MustCompile(nameReReverse) var NameRe = regexp.MustCompile(nameReReverse)

134
pkg/ssh/ssh.go Normal file
View file

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