Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 60012be942 |
12 changed files with 446 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -50,8 +50,10 @@ var (
|
|||
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
99
pkg/api/types/ssh.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ const (
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ const (
|
|||
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"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const (
|
|||
nameReReverse = "[^" + re + "]+"
|
||||
IDRoute = "/{id}"
|
||||
AIDRoute = "/{aid}"
|
||||
UserRoute = "/{user}"
|
||||
HostRoute = "/{host}"
|
||||
)
|
||||
|
||||
var NameRe = regexp.MustCompile(nameReReverse)
|
||||
|
|
|
|||
134
pkg/ssh/ssh.go
Normal file
134
pkg/ssh/ssh.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue