Compare commits

..
Author SHA1 Message Date
domrichardson fab87c82c6 feat: Updated brand to be vantage
Server Deploy / deploy (push) Successful in 2m10s
Agent Release / build (push) Successful in 1m12s
2026-06-24 15:48:13 +01:00
domrichardson 9494199306 fix: Fixed agent version on server page
Server Deploy / deploy (push) Successful in 1m12s
2026-06-24 14:40:03 +01:00
domrichardson aff6736b18 feat: update agent button on server page
Server Deploy / deploy (push) Successful in 1m39s
Agent Release / build (push) Successful in 2m10s
2026-06-24 14:33:17 +01:00
domrichardson e6ef9bc536 updates
Agent Release / build (push) Successful in 1m18s
Server Deploy / deploy (push) Successful in 1m33s
2026-06-24 13:57:48 +01:00
domrichardson 407a610cfb updates
Server Deploy / deploy (push) Successful in 1m25s
2026-06-16 11:07:18 +01:00
domrichardson 4ea7f369f1 updates
Agent Release / build (push) Successful in 44s
Server Deploy / deploy (push) Successful in 1m24s
2026-06-16 10:28:46 +01:00
domrichardson 2166a483ca updates
Server Deploy / deploy (push) Successful in 1m29s
2026-06-16 10:02:55 +01:00
domrichardson f9c3fc5379 updates
Server Deploy / deploy (push) Successful in 34s
2026-06-16 09:57:03 +01:00
domrichardson f62b0054db updates
Server Deploy / deploy (push) Successful in 1m39s
2026-06-16 09:53:12 +01:00
domrichardson de83b54be6 updates
Server Deploy / deploy (push) Successful in 1m34s
Agent Release / build (push) Successful in 10m42s
2026-06-16 09:37:32 +01:00
domrichardson aaf154168e updates
Server Deploy / deploy (push) Successful in 2m16s
2026-06-15 16:20:26 +01:00
domrichardson e215ccc979 updates
Server Deploy / deploy (push) Successful in 2m2s
2026-06-15 15:40:29 +01:00
domrichardson 7f5f082dad updates
Server Deploy / deploy (push) Successful in 1m32s
2026-06-15 15:25:09 +01:00
domrichardson abbde30b47 updates
Server Deploy / deploy (push) Successful in 2m8s
2026-06-15 15:07:35 +01:00
domrichardson 91b355cd3e updates
Server Deploy / deploy (push) Failing after 3m15s
2026-06-15 15:02:58 +01:00
domrichardson 6fa50eb2d1 updates
Server Deploy / deploy (push) Failing after 7s
2026-06-15 15:01:21 +01:00
domrichardson 679aa91bd0 updates
Server Deploy / deploy (push) Failing after 50s
2026-06-15 14:58:25 +01:00
domrichardson a0813b6e84 Updates
Server Deploy / deploy (push) Failing after 9s
Agent Release / build (push) Successful in 1m10s
2026-06-15 14:39:26 +01:00
48 changed files with 2864 additions and 558 deletions
+5 -5
View File
@@ -32,20 +32,20 @@ jobs:
mkdir -p dist
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-amd64 ./cmd
-o dist/vantage-agent-linux-amd64 ./cmd
GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-arm64 ./cmd
-o dist/vantage-agent-linux-arm64 ./cmd
- name: Checksums
working-directory: agent/dist
run: sha256sum keymanager-agent-linux-amd64 keymanager-agent-linux-arm64 > checksums.txt
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 > checksums.txt
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: |
agent/dist/keymanager-agent-linux-amd64
agent/dist/keymanager-agent-linux-arm64
agent/dist/vantage-agent-linux-amd64
agent/dist/vantage-agent-linux-arm64
agent/dist/checksums.txt
+29 -42
View File
@@ -1,50 +1,37 @@
name: Server Deploy
on:
push:
branches:
- main
paths:
- "server/**"
- "web/**"
- "proto/**"
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
deploy:
runs-on: ubuntu-docker
container: docker:dind
steps:
- name: Setup Node
run: apk add --update nodejs npm
- name: Checkout
uses: actions/checkout@v4
- name: Log in to registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login ${{ vars.GITEA_HOST }} \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Log in to registry
run: |
echo "${{ secrets.RELEASE_TOKEN }}" | \
docker login ${{ vars.DOCKER_HOST }} \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push server image
run: |
IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/server:latest"
docker build -t "$IMAGE" -f server/Dockerfile server/
docker push "$IMAGE"
- name: Build and push server image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
docker build -t "$IMAGE" -f server/Dockerfile server/
docker push "$IMAGE"
- name: Build and push web image
run: |
IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="https://${{ vars.GITEA_HOST }}" \
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Deploy via SSH
uses: https://github.com/appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/keymanager
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
- name: Build and push web image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
+10 -4
View File
@@ -1,11 +1,14 @@
package main
import (
"context"
"flag"
"log"
"os/signal"
"syscall"
"github.com/mrhid6/keymanager/agent/internal/config"
agentsync "github.com/mrhid6/keymanager/agent/internal/sync"
"github.com/mrhid6/vantage/agent/internal/config"
agentsync "github.com/mrhid6/vantage/agent/internal/sync"
)
var Version = "dev"
@@ -26,8 +29,11 @@ func main() {
return
}
log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
if err := agentsync.Run(cfg); err != nil {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Printf("vantage-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
if err := agentsync.Run(ctx, cfg, Version); err != nil {
log.Fatalf("agent error: %v", err)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/keymanager/agent
module github.com/mrhid6/vantage/agent
go 1.26
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"gopkg.in/yaml.v3"
)
const ConfigPath = "/etc/keymanager/config.yaml"
const ConfigPath = "/etc/vantage/config.yaml"
type Config struct {
ServerURL string `yaml:"server_url"`
@@ -38,7 +38,7 @@ func Save(cfg *Config) error {
if err != nil {
return err
}
if err := os.MkdirAll("/etc/keymanager", 0700); err != nil {
if err := os.MkdirAll("/etc/vantage", 0700); err != nil {
return err
}
return os.WriteFile(ConfigPath, data, 0600)
+19 -7
View File
@@ -3,9 +3,10 @@ package grpcclient
import (
"context"
"crypto/tls"
"strings"
"time"
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
@@ -18,10 +19,13 @@ func init() {
type Client struct {
conn *grpc.ClientConn
client pb.KeyManagerClient
client pb.VantageClient
}
func New(serverURL string, useTLS bool) (*Client, error) {
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
var dialOpts []grpc.DialOption
if useTLS {
@@ -44,7 +48,7 @@ func New(serverURL string, useTLS bool) (*Client, error) {
return &Client{
conn: conn,
client: pb.NewKeyManagerClient(conn),
client: pb.NewVantageClient(conn),
}, nil
}
@@ -69,13 +73,14 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
return resp.AgentToken, nil
}
func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) {
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{
ServerId: serverID,
AgentToken: agentToken,
ServerId: serverID,
AgentToken: agentToken,
AgentVersion: version,
})
if err != nil {
return nil, err
@@ -83,7 +88,7 @@ func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) {
return resp.PublicKeys, nil
}
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label string) (string, error) {
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -91,6 +96,7 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label strin
ServerId: serverID,
AgentToken: agentToken,
PublicKey: publicKey,
PrivateKey: privateKey,
Label: label,
})
if err != nil {
@@ -98,3 +104,9 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label strin
}
return resp.KeyId, nil
}
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
// The caller controls the stream lifetime via ctx.
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
-93
View File
@@ -1,93 +0,0 @@
// Hand-written gRPC bindings for keymanager.proto (agent side, JSON codec).
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
type KeyManagerClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
}
type UnimplementedKeyManagerServer struct{}
func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
+194
View File
@@ -0,0 +1,194 @@
// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec).
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
// CommandStream client-side interface
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// CommandStream server-side interface (included for completeness)
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
}
type UnimplementedVantageServer struct{}
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
if err != nil {
return nil, err
}
return &vantageCommandStreamClient{stream}, nil
}
+115 -7
View File
@@ -11,6 +11,9 @@ import (
)
const authorizedKeysPath = "/root/.ssh/authorized_keys"
const sshConfigPath = "/root/.ssh/config"
const managedConfigPath = "/root/.ssh/vantage.conf"
const includeDirective = "Include /root/.ssh/vantage.conf"
func ReadAuthorizedKeys() ([]string, error) {
data, err := os.ReadFile(authorizedKeysPath)
@@ -93,19 +96,36 @@ func fingerprint(pubKey string) string {
return "MD5:" + strings.Join(pairs, ":")
}
// GenerateKeyPair generates an ed25519 SSH keypair and returns the public key.
// KeyGenOptions controls how ssh-keygen is invoked.
type KeyGenOptions struct {
KeyType string // ed25519 (default), rsa, ecdsa
KeySize int // bits; used for rsa and ecdsa
Passphrase string // empty = no passphrase
Comment string // embedded in the public key
}
// GenerateKeyPair generates an SSH keypair and returns the public key.
// The private key is written to keyPath; keyPath+".pub" holds the public key.
func GenerateKeyPair(keyPath, comment string) (string, error) {
func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
return "", err
}
args := []string{
"-t", "ed25519",
"-f", keyPath,
"-N", "",
"-C", comment,
keyType := opts.KeyType
if keyType == "" {
keyType = "ed25519"
}
args := []string{
"-t", keyType,
"-f", keyPath,
"-N", opts.Passphrase,
"-C", opts.Comment,
}
if opts.KeySize > 0 && keyType != "ed25519" {
args = append(args, "-b", fmt.Sprintf("%d", opts.KeySize))
}
cmd := exec.Command("ssh-keygen", args...)
out, err := cmd.CombinedOutput()
if err != nil {
@@ -118,3 +138,91 @@ func GenerateKeyPair(keyPath, comment string) (string, error) {
}
return strings.TrimSpace(string(pubData)), nil
}
// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed
// vantage.conf include file, and ensures ~/.ssh/config includes it.
func AddSSHIdentity(keyPath string) error {
if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil {
return fmt.Errorf("mkdir .ssh: %w", err)
}
if err := ensureIncludeDirective(); err != nil {
return err
}
// Read existing managed config (it may not exist yet).
var existing string
data, err := os.ReadFile(managedConfigPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", managedConfigPath, err)
}
existing = string(data)
line := "IdentityFile " + keyPath
for _, l := range strings.Split(existing, "\n") {
if strings.TrimSpace(l) == line {
return nil // already present
}
}
if existing != "" && !strings.HasSuffix(existing, "\n") {
existing += "\n"
}
updated := existing + line + "\n"
if err := os.WriteFile(managedConfigPath, []byte(updated), 0600); err != nil {
return fmt.Errorf("write %s: %w", managedConfigPath, err)
}
return nil
}
// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config.
func RemoveSSHIdentity(keyPath string) error {
data, err := os.ReadFile(managedConfigPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", managedConfigPath, err)
}
line := "IdentityFile " + keyPath
var kept []string
for _, l := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
if strings.TrimSpace(l) != line {
kept = append(kept, l)
}
}
content := strings.Join(kept, "\n")
if len(kept) > 0 {
content += "\n"
}
if err := os.WriteFile(managedConfigPath, []byte(content), 0600); err != nil {
return fmt.Errorf("write %s: %w", managedConfigPath, err)
}
return nil
}
// ensureIncludeDirective adds "Include /root/.ssh/vantage.conf" to the top
// of ~/.ssh/config if it is not already present. The Include must appear before
// any Host stanzas to be effective for all connections.
func ensureIncludeDirective() error {
data, err := os.ReadFile(sshConfigPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", sshConfigPath, err)
}
for _, l := range strings.Split(string(data), "\n") {
if strings.TrimSpace(l) == includeDirective {
return nil // already present
}
}
// Prepend the Include directive so it takes effect before any Host blocks.
updated := includeDirective + "\n" + string(data)
if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil {
return fmt.Errorf("write %s: %w", sshConfigPath, err)
}
return nil
}
+262 -15
View File
@@ -1,20 +1,27 @@
package agentsync
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/mrhid6/keymanager/agent/internal/config"
grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc"
"github.com/mrhid6/keymanager/agent/internal/keys"
"github.com/mrhid6/vantage/agent/internal/config"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"github.com/mrhid6/vantage/agent/internal/keys"
)
func Run(cfg *config.Config) error {
func Run(ctx context.Context, cfg *config.Config, version string) error {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return fmt.Errorf("dial grpc: %w", err)
@@ -40,7 +47,6 @@ func Run(cfg *config.Config) error {
}
log.Println("registration successful")
// Reconnect with potentially updated state
client.Close()
client, err = grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
@@ -52,24 +58,31 @@ func Run(cfg *config.Config) error {
return fmt.Errorf("no agent token available — registration required")
}
// Start the command stream alongside the poll loop.
go runCommandStream(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
// Run immediately on startup
if err := poll(client, cfg); err != nil {
if err := poll(client, cfg, version); err != nil {
log.Printf("poll error: %v", err)
}
for range ticker.C {
if err := poll(client, cfg); err != nil {
log.Printf("poll error: %v", err)
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := poll(client, cfg, version); err != nil {
log.Printf("poll error: %v", err)
}
}
}
return nil
}
func poll(client *grpcclient.Client, cfg *config.Config) error {
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken)
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
if err != nil {
return fmt.Errorf("SyncKeys: %w", err)
}
@@ -91,6 +104,231 @@ func poll(client *grpcclient.Client, cfg *config.Config) error {
return nil
}
// runCommandStream maintains a persistent bidirectional stream with the server
// for instant command delivery. Reconnects with exponential backoff on failure.
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
for {
select {
case <-ctx.Done():
return
default:
}
if err := connectAndHandleStream(ctx, cfg); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
}
} else {
backoff = time.Second
}
}
}
func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
defer client.Close()
stream, err := client.CommandStream(ctx)
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
if err := stream.Send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Ready: &pb.AgentReady{},
}); err != nil {
return fmt.Errorf("send auth: %w", err)
}
log.Println("command stream connected")
for {
cmd, err := stream.Recv()
if err != nil {
return fmt.Errorf("recv: %w", err)
}
if cmd.GenerateKey != nil {
go handleGenerateKey(cfg, cmd)
}
if cmd.DeleteKey != nil {
go handleDeleteKey(cmd)
}
if cmd.UpdateAgent != nil {
go handleUpdateAgent(cmd)
}
}
}
func handleDeleteKey(cmd *pb.ServerCommand) {
label := cmd.DeleteKey.Label
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
if err := keys.RemoveSSHIdentity(keyPath); err != nil {
log.Printf("remove ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
}
for _, path := range []string{keyPath, keyPath + ".pub"} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Printf("delete key file %s (cmd=%s): %v", path, cmd.CommandId, err)
}
}
log.Printf("deleted local key files for %q (cmd=%s)", label, cmd.CommandId)
}
func handleUpdateAgent(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
arch := runtime.GOARCH // "amd64" or "arm64"
tag := "agent%2Fv" + u.Version
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
// Download binary
tmpBin := "/tmp/vantage-agent-update"
if err := downloadFile(binaryURL, tmpBin); err != nil {
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
return
}
// Download and verify checksum
checksumData, err := httpGetBytes(checksumURL)
if err != nil {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := verifyChecksum(tmpBin, fmt.Sprintf("vantage-agent-linux-%s", arch), checksumData); err != nil {
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
os.Remove(tmpBin)
return
}
if err := os.Chmod(tmpBin, 0755); err != nil {
log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := os.Rename(tmpBin, "/usr/local/bin/vantage-agent"); err != nil {
log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err)
return
}
log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId)
exec.Command("systemctl", "restart", "vantage-agent").Run()
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
func httpGetBytes(url string) ([]byte, error) {
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
return io.ReadAll(resp.Body)
}
func verifyChecksum(filePath, filename string, checksumData []byte) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
actual := hex.EncodeToString(h.Sum(nil))
for _, line := range strings.Split(string(checksumData), "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && fields[1] == filename {
if fields[0] != actual {
return fmt.Errorf("expected %s got %s", fields[0], actual)
}
return nil
}
}
return fmt.Errorf("no checksum entry found for %s", filename)
}
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
g := cmd.GenerateKey
label := g.Label
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
opts := keys.KeyGenOptions{
KeyType: g.KeyType,
KeySize: g.KeySize,
Passphrase: g.Passphrase,
Comment: g.Comment,
}
pubKey, err := keys.GenerateKeyPair(keyPath, opts)
if err != nil {
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
return
}
privKeyData, err := os.ReadFile(keyPath)
if err != nil {
log.Printf("read private key failed (cmd=%s): %v", cmd.CommandId, err)
return
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("dial for key upload failed (cmd=%s): %v", cmd.CommandId, err)
return
}
defer client.Close()
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
if err != nil {
log.Printf("key upload failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := keys.AddSSHIdentity(keyPath); err != nil {
log.Printf("add ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
}
log.Printf("generated and uploaded key %q (key_id=%s, cmd=%s)", label, keyID, cmd.CommandId)
}
func localIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
@@ -114,16 +352,25 @@ func GenerateAndUpload(cfg *config.Config, label string) error {
}
defer client.Close()
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
pubKey, err := keys.GenerateKeyPair(keyPath, label)
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label})
if err != nil {
return err
}
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label)
privKeyData, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
if err != nil {
return err
}
if err := keys.AddSSHIdentity(keyPath); err != nil {
log.Printf("add ssh identity: %v", err)
}
log.Printf("uploaded generated key %s (key_id=%s)", label, keyID)
return nil
}
+17 -17
View File
@@ -1,4 +1,4 @@
# KeyManager
# Vantage
A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state.
@@ -34,7 +34,7 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD
## Repository Structure
```
keymanager/
vantage/
├── agent/
│ ├── cmd/main.go
│ └── internal/
@@ -56,7 +56,7 @@ keymanager/
│ ├── app/
│ └── components/
├── proto/
│ └── keymanager/v1/keymanager.proto
│ └── vantage/v1/vantage.proto
├── deploy/
│ ├── docker-compose.yml
│ └── agent.service
@@ -72,9 +72,9 @@ keymanager/
```protobuf
syntax = "proto3";
package keymanager.v1;
package vantage.v1;
service KeyManager {
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
@@ -172,10 +172,10 @@ No streaming — polling only. Poll interval: **30 seconds**.
## Agent Lifecycle
### Config file — `/etc/keymanager/config.yaml`
### Config file — `/etc/vantage/config.yaml`
```yaml
server_url: "keymanager.yourdomain.com:9090"
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # removed after first successful Register()
agent_token: "" # written by agent after Register()
@@ -216,15 +216,15 @@ Config file permissions: `0600`. Config directory: `0700`.
- Uploads public key via `UploadGeneratedKey()`
- Private key stays local on the machine
### Systemd unit — `/etc/systemd/system/keymanager-agent.service`
### Systemd unit — `/etc/systemd/system/vantage-agent.service`
```ini
[Unit]
Description=KeyManager Agent
Description=Vantage Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/keymanager-agent
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
@@ -241,14 +241,14 @@ WantedBy=multi-user.target
2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id`
3. UI displays a one-liner install command with copy button:
```bash
curl -fsSL https://keymanager.yourdomain.com/install | \
curl -fsSL https://vantage.yourdomain.com/install | \
bash -s -- --server-id=<id> --token=<token>
```
4. Install script:
- Detects arch (`amd64` / `arm64`)
- Downloads agent binary from Gitea release
- Verifies SHA-256 checksum
- Writes `/etc/keymanager/config.yaml`
- Writes `/etc/vantage/config.yaml`
- Installs and starts systemd unit
5. On first `SyncKeys` call, server marks status as `active`
@@ -259,7 +259,7 @@ The backend serves `/install` dynamically, injecting the latest agent version by
## Security
- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent)
- Agent authenticates with a per-server token stored at `/etc/keymanager/config.yaml` (`0600`)
- Agent authenticates with a per-server token stored at `/etc/vantage/config.yaml` (`0600`)
- Server stores `SHA-256(agent_token)` — never the plaintext token
- Private keys generated by agents are encrypted at rest in MongoDB (AES-256)
- `authorized_keys` written as `0600`, owned by root
@@ -298,13 +298,13 @@ Build command:
```bash
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-amd64 ./cmd
-o dist/vantage-agent-linux-amd64 ./cmd
```
Release assets:
- `keymanager-agent-linux-amd64`
- `keymanager-agent-linux-arm64`
- `vantage-agent-linux-amd64`
- `vantage-agent-linux-arm64`
- `checksums.txt`
### Server deploy — `.gitea/workflows/server-deploy.yml`
@@ -312,7 +312,7 @@ Release assets:
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
```bash
cd /opt/keymanager && docker compose pull && docker compose up -d --remove-orphans
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
```
### Tagging convention
+4 -4
View File
@@ -1,18 +1,18 @@
[Unit]
Description=KeyManager Agent
Documentation=https://github.com/your-org/keymanager
Description=Vantage Agent
Documentation=https://github.com/your-org/vantage
After=network.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/keymanager-agent
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=keymanager-agent
SyslogIdentifier=vantage-agent
# Security hardening
NoNewPrivileges=true
+17
View File
@@ -0,0 +1,17 @@
services:
migrate:
image: mongo:8
depends_on:
mongo:
condition: service_healthy
volumes:
- ./migrate/server-migrate.sh:/migrate.sh:ro
command: bash /migrate.sh
environment:
MONGO_HOST: mongo
MONGO_PORT: "27017"
SRC_DB: keymanager
DST_DB: vantage
# Set DROP_SRC=true to automatically drop the keymanager database after migration
DROP_SRC: "false"
restart: "no"
+21 -1
View File
@@ -11,6 +11,17 @@ services:
retries: 5
start_period: 20s
redis:
image: redis:8
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
server:
build:
context: ../server
@@ -20,14 +31,22 @@ services:
- "8080:8080"
- "9090:9090"
environment:
MONGO_URI: mongodb://mongo:27017/keymanager
MONGO_URI: mongodb://mongo:27017/vantage
REDIS_ADDR: redis:6379
GITEA_HOST: ${GITEA_HOST}
PUBLIC_HOST: ${PUBLIC_HOST}
GRPC_HOST: ${GRPC_HOST}
GRPC_PORT: "9090"
HTTP_PORT: "8080"
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
web:
build:
@@ -43,3 +62,4 @@ services:
volumes:
mongo_data:
redis_data:
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
# Migrates an existing keymanager-agent installation to vantage-agent.
# Run as root on each managed server.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Must be run as root"
GITEA_HOST="${GITEA_HOST:-}"
GITEA_OWNER="${GITEA_OWNER:-}"
# ---------------------------------------------------------------------------
# 1. Detect old installation
# ---------------------------------------------------------------------------
OLD_BINARY="/usr/local/bin/keymanager-agent"
OLD_CONFIG_DIR="/etc/keymanager"
OLD_CONFIG="$OLD_CONFIG_DIR/config.yaml"
OLD_SERVICE="keymanager-agent"
OLD_SERVICE_FILE="/etc/systemd/system/${OLD_SERVICE}.service"
OLD_SSH_CONF="/root/.ssh/keymanager.conf"
OLD_SSH_CONFIG="/root/.ssh/config"
NEW_BINARY="/usr/local/bin/vantage-agent"
NEW_CONFIG_DIR="/etc/vantage"
NEW_CONFIG="$NEW_CONFIG_DIR/config.yaml"
NEW_SERVICE="vantage-agent"
NEW_SERVICE_FILE="/etc/systemd/system/${NEW_SERVICE}.service"
NEW_SSH_CONF="/root/.ssh/vantage.conf"
if [ ! -f "$OLD_CONFIG" ] && [ ! -f "$OLD_BINARY" ]; then
warn "No keymanager-agent installation found — nothing to migrate."
exit 0
fi
info "Found keymanager-agent installation. Starting migration to vantage-agent..."
# ---------------------------------------------------------------------------
# 2. Stop and disable old service
# ---------------------------------------------------------------------------
if systemctl is-active --quiet "$OLD_SERVICE" 2>/dev/null; then
info "Stopping $OLD_SERVICE..."
systemctl stop "$OLD_SERVICE"
fi
if systemctl is-enabled --quiet "$OLD_SERVICE" 2>/dev/null; then
systemctl disable "$OLD_SERVICE"
fi
# ---------------------------------------------------------------------------
# 3. Migrate config directory
# ---------------------------------------------------------------------------
if [ -f "$OLD_CONFIG" ] && [ ! -f "$NEW_CONFIG" ]; then
info "Migrating config: $OLD_CONFIG -> $NEW_CONFIG"
mkdir -p "$NEW_CONFIG_DIR"
chmod 0700 "$NEW_CONFIG_DIR"
cp "$OLD_CONFIG" "$NEW_CONFIG"
chmod 0600 "$NEW_CONFIG"
elif [ -f "$NEW_CONFIG" ]; then
warn "$NEW_CONFIG already exists — skipping config copy."
fi
# ---------------------------------------------------------------------------
# 4. Migrate SSH managed conf file
# ---------------------------------------------------------------------------
if [ -f "$OLD_SSH_CONF" ]; then
info "Migrating SSH conf: $OLD_SSH_CONF -> $NEW_SSH_CONF"
# Rewrite IdentityFile paths: /root/.ssh/keymanager_* -> /root/.ssh/vantage_*
sed 's|/root/\.ssh/keymanager_|/root/.ssh/vantage_|g' "$OLD_SSH_CONF" > "$NEW_SSH_CONF"
chmod 0600 "$NEW_SSH_CONF"
fi
# Update Include directive in /root/.ssh/config
if [ -f "$OLD_SSH_CONFIG" ]; then
if grep -q "Include /root/.ssh/keymanager.conf" "$OLD_SSH_CONFIG"; then
info "Updating Include directive in $OLD_SSH_CONFIG"
sed -i 's|Include /root/\.ssh/keymanager\.conf|Include /root/.ssh/vantage.conf|g' "$OLD_SSH_CONFIG"
fi
fi
# ---------------------------------------------------------------------------
# 5. Rename generated key files
# ---------------------------------------------------------------------------
shopt -s nullglob
OLD_KEYS=(/root/.ssh/keymanager_*)
if [ ${#OLD_KEYS[@]} -gt 0 ]; then
info "Renaming ${#OLD_KEYS[@]} key file(s)..."
for old_path in "${OLD_KEYS[@]}"; do
filename=$(basename "$old_path")
new_filename="${filename/keymanager_/vantage_}"
new_path="/root/.ssh/$new_filename"
if [ ! -e "$new_path" ]; then
cp "$old_path" "$new_path"
chmod "$(stat -c '%a' "$old_path")" "$new_path"
info " $old_path -> $new_path"
else
warn " $new_path already exists — skipping"
fi
done
fi
shopt -u nullglob
# ---------------------------------------------------------------------------
# 6. Download new vantage-agent binary
# ---------------------------------------------------------------------------
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) die "Unsupported architecture: $ARCH" ;;
esac
if [ -n "$GITEA_HOST" ] && [ -n "$GITEA_OWNER" ]; then
info "Fetching latest vantage-agent release from $GITEA_HOST..."
RELEASE_JSON=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/vantage/releases?limit=1&type=tag" 2>/dev/null || echo "")
if [ -n "$RELEASE_JSON" ]; then
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*vantage-agent-linux-${ARCH}\"" | head -1 | cut -d'"' -f4)
CHECKSUM_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*checksums\.txt\"" | head -1 | cut -d'"' -f4)
if [ -n "$DOWNLOAD_URL" ]; then
info "Downloading $DOWNLOAD_URL..."
TMP_BIN="/tmp/vantage-agent-new"
curl -fsSL -o "$TMP_BIN" "$DOWNLOAD_URL"
if [ -n "$CHECKSUM_URL" ]; then
TMP_SUMS="/tmp/vantage-checksums.txt"
curl -fsSL -o "$TMP_SUMS" "$CHECKSUM_URL"
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" "$TMP_SUMS" | awk '{print $1}')
ACTUAL=$(sha256sum "$TMP_BIN" | awk '{print $1}')
[ "$EXPECTED" = "$ACTUAL" ] || die "Checksum mismatch! Expected $EXPECTED, got $ACTUAL"
rm -f "$TMP_SUMS"
info "Checksum verified."
fi
chmod 0755 "$TMP_BIN"
mv "$TMP_BIN" "$NEW_BINARY"
info "Installed $NEW_BINARY"
else
warn "Could not find vantage-agent binary in release — skipping binary install."
fi
else
warn "Could not reach Gitea API — skipping binary download."
fi
elif [ -f "$OLD_BINARY" ]; then
warn "GITEA_HOST/GITEA_OWNER not set — skipping binary download."
warn "You must manually install the vantage-agent binary to $NEW_BINARY before starting the service."
fi
# ---------------------------------------------------------------------------
# 7. Install new systemd service
# ---------------------------------------------------------------------------
info "Installing $NEW_SERVICE_FILE..."
cat > "$NEW_SERVICE_FILE" <<'EOF'
[Unit]
Description=Vantage Agent
Documentation=https://github.com/your-org/vantage
After=network.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=vantage-agent
NoNewPrivileges=true
ProtectSystem=false
ProtectHome=false
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$NEW_SERVICE"
# ---------------------------------------------------------------------------
# 8. Start new service (only if binary exists)
# ---------------------------------------------------------------------------
if [ -f "$NEW_BINARY" ]; then
info "Starting $NEW_SERVICE..."
systemctl start "$NEW_SERVICE"
sleep 2
if systemctl is-active --quiet "$NEW_SERVICE"; then
info "vantage-agent is running."
else
warn "vantage-agent failed to start. Check: journalctl -u vantage-agent"
fi
else
warn "Binary not yet installed — service NOT started."
warn "Install the binary then run: systemctl start vantage-agent"
fi
# ---------------------------------------------------------------------------
# 9. Clean up old installation
# ---------------------------------------------------------------------------
info "Cleaning up old keymanager-agent files..."
rm -f "$OLD_SERVICE_FILE"
rm -f "$OLD_BINARY"
rm -rf "$OLD_CONFIG_DIR"
rm -f "$OLD_SSH_CONF"
shopt -s nullglob
for old_key in /root/.ssh/keymanager_*; do
rm -f "$old_key"
done
shopt -u nullglob
systemctl daemon-reload
info "Migration complete."
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Runs inside the migration container.
# Copies all collections + indexes from $SRC_DB to $DST_DB,
# verifies document counts, then optionally drops the source.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
MONGO_HOST="${MONGO_HOST:-mongo}"
MONGO_PORT="${MONGO_PORT:-27017}"
SRC_DB="${SRC_DB:-keymanager}"
DST_DB="${DST_DB:-vantage}"
DROP_SRC="${DROP_SRC:-false}"
MONGO_URI="mongodb://${MONGO_HOST}:${MONGO_PORT}"
mongosh_eval() {
local db="$1"; local script="$2"
mongosh --quiet "${MONGO_URI}/${db}" --eval "$script"
}
# ---------------------------------------------------------------------------
# 1. Wait for MongoDB to be reachable
# ---------------------------------------------------------------------------
info "Waiting for MongoDB at ${MONGO_HOST}:${MONGO_PORT}..."
for i in $(seq 1 30); do
mongosh --quiet "${MONGO_URI}/admin" --eval "db.adminCommand('ping')" >/dev/null 2>&1 && break
[ "$i" -eq 30 ] && die "MongoDB not reachable after 30 attempts."
sleep 2
done
info "MongoDB is ready."
# ---------------------------------------------------------------------------
# 2. Check source database
# ---------------------------------------------------------------------------
SRC_COLLECTIONS=$(mongosh_eval admin "
const names = db.getSiblingDB('${SRC_DB}').getCollectionNames();
print(names.join(','));
")
if [ -z "$SRC_COLLECTIONS" ] || [ "$SRC_COLLECTIONS" = "," ]; then
warn "Source database '${SRC_DB}' has no collections — nothing to migrate."
warn "If this is a fresh deployment, '${DST_DB}' will be created automatically."
exit 0
fi
info "Collections in '${SRC_DB}': ${SRC_COLLECTIONS}"
# ---------------------------------------------------------------------------
# 3. Copy all collections via \$out
# ---------------------------------------------------------------------------
info "Copying collections from '${SRC_DB}' to '${DST_DB}'..."
mongosh_eval admin "
const src = db.getSiblingDB('${SRC_DB}');
const cols = src.getCollectionNames();
cols.forEach(function(name) {
src[name].aggregate([{ \\\$out: { db: '${DST_DB}', coll: name } }]);
print('Copied: ' + name);
});
"
# ---------------------------------------------------------------------------
# 4. Recreate indexes
# ---------------------------------------------------------------------------
info "Recreating indexes in '${DST_DB}'..."
mongosh_eval admin "
const src = db.getSiblingDB('${SRC_DB}');
const dst = db.getSiblingDB('${DST_DB}');
src.getCollectionNames().forEach(function(col) {
src[col].getIndexes().forEach(function(idx) {
if (idx.name === '_id_') return;
const opts = { name: idx.name };
if (idx.unique) opts.unique = true;
if (idx.sparse) opts.sparse = true;
if (idx.expireAfterSeconds !== undefined) opts.expireAfterSeconds = idx.expireAfterSeconds;
try {
dst[col].createIndex(idx.key, opts);
print('Index: ' + col + '.' + idx.name);
} catch(e) {
print('Skipped index ' + idx.name + ' on ' + col + ': ' + e.message);
}
});
});
"
# ---------------------------------------------------------------------------
# 5. Verify document counts
# ---------------------------------------------------------------------------
info "Verifying document counts..."
MISMATCH=0
IFS=',' read -ra COLS <<< "$SRC_COLLECTIONS"
for col in "${COLS[@]}"; do
[ -z "$col" ] && continue
SRC_N=$(mongosh_eval "$SRC_DB" "print(db['${col}'].countDocuments())")
DST_N=$(mongosh_eval "$DST_DB" "print(db['${col}'].countDocuments())")
if [ "$SRC_N" = "$DST_N" ]; then
info " ${col}: ${SRC_N} docs OK"
else
warn " ${col}: src=${SRC_N} dst=${DST_N} MISMATCH"
MISMATCH=1
fi
done
[ "$MISMATCH" -eq 1 ] && die "Count mismatch — source database NOT dropped. Investigate and re-run."
# ---------------------------------------------------------------------------
# 6. Optionally drop source database
# ---------------------------------------------------------------------------
if [ "$DROP_SRC" = "true" ]; then
info "Dropping source database '${SRC_DB}'..."
mongosh_eval admin "db.getSiblingDB('${SRC_DB}').dropDatabase(); print('Dropped.');"
info "Dropped '${SRC_DB}'."
else
warn "Source database '${SRC_DB}' kept. Set DROP_SRC=true to drop it automatically."
fi
info "Migration complete."
-43
View File
@@ -1,43 +0,0 @@
syntax = "proto3";
package keymanager.v1;
option go_package = "github.com/mrhid6/keymanager/server/internal/grpc/pb";
service KeyManager {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
}
message RegisterRequest {
string server_id = 1;
string pre_reg_token = 2;
string hostname = 3;
string ip_address = 4;
string os_info = 5;
}
message RegisterResponse {
string agent_token = 1;
}
message SyncRequest {
string server_id = 1;
string agent_token = 2;
}
message SyncResponse {
repeated string public_keys = 1;
}
message UploadKeyRequest {
string server_id = 1;
string agent_token = 2;
string public_key = 3;
string label = 4;
}
message UploadKeyResponse {
string key_id = 1;
}
+92
View File
@@ -0,0 +1,92 @@
syntax = "proto3";
package vantage.v1;
option go_package = "github.com/mrhid6/vantage/server/internal/grpc/pb";
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
// Bidirectional stream: agent sends auth once, server pushes commands.
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
message RegisterRequest {
string server_id = 1;
string pre_reg_token = 2;
string hostname = 3;
string ip_address = 4;
string os_info = 5;
}
message RegisterResponse {
string agent_token = 1;
}
message SyncRequest {
string server_id = 1;
string agent_token = 2;
string agent_version = 3;
}
message SyncResponse {
repeated string public_keys = 1;
}
message UploadKeyRequest {
string server_id = 1;
string agent_token = 2;
string public_key = 3;
string label = 4;
string private_key = 5;
}
message UploadKeyResponse {
string key_id = 1;
}
// CommandStream messages
message AgentMessage {
string server_id = 1;
string agent_token = 2;
oneof payload {
AgentReady ready = 3;
CommandResult result = 4;
}
}
message AgentReady {}
message CommandResult {
string command_id = 1;
bool success = 2;
string message = 3;
}
message ServerCommand {
string command_id = 1;
oneof command {
GenerateKeyCmd generate_key = 2;
DeleteKeyCmd delete_key = 3;
UpdateAgentCmd update_agent = 4;
}
}
message DeleteKeyCmd {
string label = 1;
}
message UpdateAgentCmd {
string version = 1; // e.g. "1.2.3"
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
}
message GenerateKeyCmd {
string label = 1;
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
int32 key_size = 3; // bits; used for rsa and ecdsa
string passphrase = 4; // empty = no passphrase
string comment = 5; // embedded in public key
}
+3 -3
View File
@@ -11,14 +11,14 @@ RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /keymanager-server ./cmd
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
# Runtime stage
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /keymanager-server /keymanager-server
COPY --from=builder /vantage-server /vantage-server
EXPOSE 8080 9090
ENTRYPOINT ["/keymanager-server"]
ENTRYPOINT ["/vantage-server"]
+17 -5
View File
@@ -1,26 +1,38 @@
package main
import (
"context"
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/keymanager/server/internal/api"
"github.com/mrhid6/keymanager/server/internal/db"
grpcserver "github.com/mrhid6/keymanager/server/internal/grpc"
"github.com/mrhid6/keymanager/server/internal/services"
"github.com/mrhid6/vantage/server/internal/api"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/db"
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
"github.com/mrhid6/vantage/server/internal/services"
)
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
dbName := getEnv("MONGO_DB", "keymanager")
dbName := getEnv("MONGO_DB", "vantage")
if err := db.Connect(mongoURI, dbName); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Println("connected to MongoDB")
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
if err := auth.InitRedis(redisAddr); err != nil {
log.Fatalf("failed to connect to Redis: %v", err)
}
log.Println("connected to Redis")
if err := auth.InitOIDC(context.Background()); err != nil {
log.Fatalf("failed to initialise OIDC: %v", err)
}
// Background goroutine to mark offline servers
go func() {
ticker := time.NewTicker(2 * time.Minute)
+8 -2
View File
@@ -1,21 +1,26 @@
module github.com/mrhid6/keymanager/server
module github.com/mrhid6/vantage/server
go 1.26
require (
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.2.2
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
@@ -23,7 +28,7 @@ require (
github.com/golang/snappy v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -35,6 +40,7 @@ require (
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.25.0 // indirect
+20 -3
View File
@@ -1,11 +1,19 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,6 +23,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -37,8 +47,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
@@ -53,6 +63,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -78,8 +90,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -93,6 +109,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
@@ -102,7 +120,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+207 -49
View File
@@ -6,28 +6,43 @@ import (
"os"
"github.com/gin-gonic/gin"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/keymanager/server/internal/services"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/update", handleUpdateScript)
api := r.Group("/api")
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
r.GET("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
// API endpoints protected by session middleware
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
{
api.GET("/servers", listServers)
api.POST("/servers", createServer)
api.GET("/servers/new", newServer)
api.POST("/servers/new", newServer)
api.GET("/servers/:id", getServer)
api.DELETE("/servers/:id", deleteServer)
api.POST("/servers/:id/generate-key", generateKey)
apiGroup.GET("/servers", listServers)
apiGroup.POST("/servers", createServer)
apiGroup.GET("/servers/new", newServer)
apiGroup.POST("/servers/new", newServer)
apiGroup.GET("/servers/:id", getServer)
apiGroup.DELETE("/servers/:id", deleteServer)
apiGroup.POST("/servers/:id/generate-key", generateKey)
apiGroup.POST("/servers/:id/update-agent", updateAgent)
api.GET("/keys", listKeys)
api.POST("/keys", createKey)
api.GET("/keys/:id", getKey)
api.POST("/keys/:id/assign", assignKey)
api.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
apiGroup.DELETE("/keys/:id", deleteKey)
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
}
}
@@ -66,11 +81,11 @@ func newServer(c *gin.Context) {
}
host := os.Getenv("PUBLIC_HOST")
if host == "" {
host = "keymanager.example.com"
host = "https://vantage.example.com"
}
installCmd := fmt.Sprintf(
`curl -fsSL "https://%s/install?server_id=%s&token=%s" | bash`,
`curl -fsSL "%s/install?server_id=%s&token=%s" | bash`,
host, s.ServerID, token,
)
@@ -112,18 +127,42 @@ func deleteServer(c *gin.Context) {
}
func generateKey(c *gin.Context) {
// The agent triggers key generation itself; this endpoint signals
// the intent by returning the server so the caller knows to wait
// for the agent to upload via gRPC UploadGeneratedKey.
id := c.Param("id")
var body struct {
Label string `json:"label"`
KeyType string `json:"key_type"`
KeySize int `json:"key_size"`
Passphrase string `json:"passphrase"`
Comment string `json:"comment"`
}
_ = c.ShouldBindJSON(&body)
if body.Label == "" {
body.Label = "generated"
}
s, err := services.GetServer(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "agent will generate and upload key on next poll",
"server_id": s.ServerID,
cmdID, err := services.DispatchGenerateKey(s.ServerID, services.KeyGenParams{
Label: body.Label,
KeyType: body.KeyType,
KeySize: body.KeySize,
Passphrase: body.Passphrase,
Comment: body.Comment,
})
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
"server_id": s.ServerID,
})
}
@@ -138,15 +177,16 @@ func listKeys(c *gin.Context) {
func createKey(c *gin.Context) {
var body struct {
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
PrivateKey string `json:"private_key"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "")
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -154,6 +194,16 @@ func createKey(c *gin.Context) {
c.JSON(http.StatusCreated, key)
}
func getPrivateKey(c *gin.Context) {
id := c.Param("id")
plaintext, err := services.GetPrivateKey(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
}
func getKey(c *gin.Context) {
id := c.Param("id")
key, err := services.GetKey(id)
@@ -163,12 +213,26 @@ func getKey(c *gin.Context) {
}
assignments, _ := services.GetAssignmentsWithServers(id)
c.JSON(http.StatusOK, gin.H{
"key": key,
"assignments": assignments,
type keyResponse struct {
*models.Key
Assignments any `json:"assignments"`
}
c.JSON(http.StatusOK, keyResponse{
Key: key,
Assignments: assignments,
})
}
func deleteKey(c *gin.Context) {
id := c.Param("id")
if err := services.DeleteKey(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func assignKey(c *gin.Context) {
keyID := c.Param("id")
var body struct {
@@ -198,6 +262,90 @@ func revokeAssignment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
func getLatestAgentVersion(c *gin.Context) {
version, err := services.GetLatestAgentVersion()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"version": version})
}
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
version, err := services.DispatchUpdateAgent(s.ServerID)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
})
}
func handleUpdateScript(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
script := fmt.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
GITEA_HOST="%s"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
# Get latest agent release tag
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
if [ -z "$LATEST" ]; then
echo "Could not determine latest agent version" >&2
exit 1
fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
echo "Updating vantage-agent to ${VERSION} (${ARCH})..."
curl -fsSL -o /tmp/vantage-agent "${BINARY_URL}"
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
cd /tmp
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum vantage-agent | awk '{print $1}')
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!" >&2
exit 1
fi
systemctl stop vantage-agent || true
install -m 0755 /tmp/vantage-agent /usr/local/bin/vantage-agent
systemctl start vantage-agent
echo "vantage-agent updated to ${VERSION} and restarted."
`, giteaHost)
c.Header("Content-Type", "text/x-shellscript")
c.String(http.StatusOK, script)
}
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
@@ -208,7 +356,11 @@ func handleInstallScript(c *gin.Context) {
}
publicHost := os.Getenv("PUBLIC_HOST")
if publicHost == "" {
publicHost = "keymanager.example.com"
publicHost = "vantage.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
grpcHost = publicHost
}
script := fmt.Sprintf(`#!/usr/bin/env bash
@@ -218,6 +370,11 @@ SERVER_ID="%s"
TOKEN="%s"
GITEA_HOST="%s"
KM_HOST="%s"
KM_HOST="${KM_HOST#https://}"
KM_HOST="${KM_HOST#http://}"
GRPC_HOST="%s"
GRPC_HOST="${GRPC_HOST#https://}"
GRPC_HOST="${GRPC_HOST#http://}"
ARCH=$(uname -m)
case "$ARCH" in
@@ -227,7 +384,7 @@ case "$ARCH" in
esac
# Get latest agent release tag
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/keymanager/releases?limit=10" \
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
if [ -z "$LATEST" ]; then
@@ -236,44 +393,45 @@ if [ -z "$LATEST" ]; then
fi
VERSION="${LATEST#agent/}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/keymanager-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/checksums.txt"
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
echo "Installing keymanager-agent ${VERSION} (${ARCH})..."
echo "Installing vantage-agent ${VERSION} (${ARCH})..."
curl -fsSL -o /tmp/keymanager-agent "${BINARY_URL}"
curl -fsSL -o /tmp/vantage-agent "${BINARY_URL}"
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
cd /tmp
EXPECTED=$(grep "keymanager-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum keymanager-agent | awk '{print $1}')
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum vantage-agent | awk '{print $1}')
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!" >&2
exit 1
fi
install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent
install -m 0755 /tmp/vantage-agent /usr/local/bin/vantage-agent
mkdir -p /etc/keymanager
chmod 0700 /etc/keymanager
mkdir -p /etc/vantage
chmod 0700 /etc/vantage
cat > /etc/keymanager/config.yaml <<EOF
server_url: "${KM_HOST}:9090"
cat > /etc/vantage/config.yaml <<EOF
server_url: "${GRPC_HOST}"
server_id: "${SERVER_ID}"
pre_reg_token: "${TOKEN}"
agent_token: ""
poll_interval: 30s
tls: true
EOF
chmod 0600 /etc/keymanager/config.yaml
chmod 0600 /etc/vantage/config.yaml
cat > /etc/systemd/system/keymanager-agent.service <<EOF
cat > /etc/systemd/system/vantage-agent.service <<EOF
[Unit]
Description=KeyManager Agent
Description=Vantage Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/keymanager-agent
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
@@ -283,10 +441,10 @@ WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now keymanager-agent
systemctl enable --now vantage-agent
echo "keymanager-agent installed and started."
`, serverID, token, giteaHost, publicHost)
echo "vantage-agent installed and started."
`, serverID, token, giteaHost, publicHost, grpcHost)
c.Header("Content-Type", "text/x-shellscript")
c.String(http.StatusOK, script)
+39
View File
@@ -0,0 +1,39 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
)
const ctxSessionKey = "km_session"
func GetSessionFromContext(c *gin.Context) *Session {
v, _ := c.Get(ctxSessionKey)
sess, _ := v.(*Session)
return sess
}
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !authEnabled {
c.Next()
return
}
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
c.Set(ctxSessionKey, sess)
c.Next()
}
}
+154
View File
@@ -0,0 +1,154 @@
package auth
import (
"context"
"log"
"net/http"
"os"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)
var (
oidcProvider *oidc.Provider
oauth2Cfg *oauth2.Config
authEnabled bool
)
func InitOIDC(ctx context.Context) error {
issuer := os.Getenv("OIDC_ISSUER")
if issuer == "" {
log.Println("OIDC_ISSUER not set; authentication disabled")
return nil
}
p, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return err
}
oidcProvider = p
oauth2Cfg = &oauth2.Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Endpoint: p.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
authEnabled = true
log.Println("OIDC authentication enabled")
return nil
}
func Enabled() bool { return authEnabled }
func HandleLogin(c *gin.Context) {
state, err := randomHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
return
}
if err := SaveState(c.Request.Context(), state); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
return
}
c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state))
}
func HandleCallback(c *gin.Context) {
ctx := c.Request.Context()
if !ConsumeState(ctx, c.Query("state")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
token, err := oauth2Cfg.Exchange(ctx, c.Query("code"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
return
}
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"})
return
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
return
}
sessionID, err := SaveSession(ctx, &Session{
UserID: claims.Sub,
Email: claims.Email,
Name: claims.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
frontendURL := os.Getenv("PUBLIC_HOST")
if frontendURL == "" {
frontendURL = "/"
}
c.Redirect(http.StatusFound, frontendURL)
}
func HandleLogout(c *gin.Context) {
if cookie, err := c.Request.Cookie(sessionCookieName); err == nil {
_ = DeleteSession(c.Request.Context(), cookie.Value)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
c.Redirect(http.StatusFound, "/")
}
func HandleMe(c *gin.Context) {
if !authEnabled {
c.JSON(http.StatusOK, gin.H{"auth_enabled": false})
return
}
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
c.JSON(http.StatusOK, sess)
}
+79
View File
@@ -0,0 +1,79 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
const sessionTTL = 24 * time.Hour
const sessionCookieName = "km_session"
const sessionPrefix = "km:session:"
const statePrefix = "km:state:"
type Session struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
}
var rdb *redis.Client
func InitRedis(addr string) error {
rdb = redis.NewClient(&redis.Options{Addr: addr})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return rdb.Ping(ctx).Err()
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func SaveSession(ctx context.Context, sess *Session) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
data, err := json.Marshal(sess)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, sessionPrefix+id, data, sessionTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func GetSession(ctx context.Context, id string) (*Session, error) {
data, err := rdb.Get(ctx, sessionPrefix+id).Bytes()
if err != nil {
return nil, err
}
var sess Session
if err := json.Unmarshal(data, &sess); err != nil {
return nil, err
}
return &sess, nil
}
func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveState(ctx context.Context, state string) error {
return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err()
}
func ConsumeState(ctx context.Context, state string) bool {
n, err := rdb.Del(ctx, statePrefix+state).Result()
return err == nil && n > 0
}
-171
View File
@@ -1,171 +0,0 @@
// Hand-written gRPC bindings for keymanager.proto using JSON codec.
// To use: register the JSON codec before creating gRPC servers/clients.
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Message types
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// Server interface
type KeyManagerServer interface {
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
}
type UnimplementedKeyManagerServer struct{}
func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented")
}
func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
}
// Client interface
type KeyManagerClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
// Server registration
func RegisterKeyManagerServer(s grpc.ServiceRegistrar, srv KeyManagerServer) {
s.RegisterService(&KeyManager_ServiceDesc, srv)
}
var KeyManager_ServiceDesc = grpc.ServiceDesc{
ServiceName: "keymanager.v1.KeyManager",
HandlerType: (*KeyManagerServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Register", Handler: _KeyManager_Register_Handler},
{MethodName: "SyncKeys", Handler: _KeyManager_SyncKeys_Handler},
{MethodName: "UploadGeneratedKey", Handler: _KeyManager_UploadGeneratedKey_Handler},
},
Streams: []grpc.StreamDesc{},
Metadata: "keymanager/v1/keymanager.proto",
}
func _KeyManager_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyManagerServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/Register"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyManagerServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyManager_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyManagerServer).SyncKeys(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/SyncKeys"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyManagerServer).SyncKeys(ctx, req.(*SyncRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyManager_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UploadKeyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyManagerServer).UploadGeneratedKey(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/UploadGeneratedKey"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyManagerServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest))
}
return interceptor(ctx, in, info, handler)
}
+287
View File
@@ -0,0 +1,287 @@
// Hand-written gRPC bindings for vantage.proto using JSON codec.
// To use: register the JSON codec before creating gRPC servers/clients.
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Message types
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
// CommandStream server-side interface
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// CommandStream client-side interface
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// Server interface
type VantageServer interface {
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
CommandStream(Vantage_CommandStreamServer) error
}
type UnimplementedVantageServer struct{}
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented")
}
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
}
func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) error {
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
}
// Client interface
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[0], "/vantage.v1.Vantage/CommandStream", opts...)
if err != nil {
return nil, err
}
return &vantageCommandStreamClient{stream}, nil
}
// Server registration
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
s.RegisterService(&Vantage_ServiceDesc, srv)
}
var Vantage_ServiceDesc = grpc.ServiceDesc{
ServiceName: "vantage.v1.Vantage",
HandlerType: (*VantageServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Register", Handler: _Vantage_Register_Handler},
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
},
Streams: []grpc.StreamDesc{
{
StreamName: "CommandStream",
Handler: _Vantage_CommandStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "vantage/v1/vantage.proto",
}
func _Vantage_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/Register"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).SyncKeys(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncKeys"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).SyncKeys(ctx, req.(*SyncRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UploadKeyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).UploadGeneratedKey(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/UploadGeneratedKey"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(VantageServer).CommandStream(&keyManagerCommandStreamServer{stream})
}
+63 -10
View File
@@ -6,8 +6,8 @@ import (
"log"
"net"
"github.com/mrhid6/keymanager/server/internal/grpc/pb"
"github.com/mrhid6/keymanager/server/internal/services"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"github.com/mrhid6/vantage/server/internal/services"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/encoding"
@@ -18,11 +18,11 @@ func init() {
encoding.RegisterCodec(JSONCodec{})
}
type keyManagerServer struct {
pb.UnimplementedKeyManagerServer
type vantageServer struct {
pb.UnimplementedVantageServer
}
func (s *keyManagerServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
func (s *vantageServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
agentToken, err := services.RegisterServer(req.ServerId, req.PreRegToken, req.Hostname, req.IpAddress, req.OsInfo)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "registration failed: %v", err)
@@ -30,13 +30,13 @@ func (s *keyManagerServer) Register(ctx context.Context, req *pb.RegisterRequest
return &pb.RegisterResponse{AgentToken: agentToken}, nil
}
func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
func (s *vantageServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
if err := services.UpdateServerLastSeen(srv.ServerID); err != nil {
if err := services.UpdateServerLastSeen(srv.ServerID, req.AgentVersion); err != nil {
log.Printf("failed to update last seen for %s: %v", srv.ServerID, err)
}
@@ -48,13 +48,13 @@ func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*
return &pb.SyncResponse{PublicKeys: keys}, nil
}
func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID)
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
@@ -67,6 +67,59 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
}
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
// First message authenticates the agent and signals readiness.
msg, err := stream.Recv()
if err != nil {
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
}
srv, err := services.ValidateAgentToken(msg.ServerId, msg.AgentToken)
if err != nil {
return status.Errorf(codes.Unauthenticated, "invalid agent token")
}
if err := services.UpdateServerLastSeen(srv.ServerID, ""); err != nil {
log.Printf("update last seen %s: %v", srv.ServerID, err)
}
ch := services.Dispatcher.Connect(srv.ServerID)
defer services.Dispatcher.Disconnect(srv.ServerID)
log.Printf("agent %s connected command stream", srv.ServerID)
defer log.Printf("agent %s disconnected command stream", srv.ServerID)
// Drain inbound results in the background so client Send calls never block.
// UploadGeneratedKey handles the real storage; these are just confirmation logs.
go func() {
for {
m, err := stream.Recv()
if err != nil {
return
}
if m.Result != nil {
r := m.Result
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
}
}
}()
ctx := stream.Context()
for {
select {
case <-ctx.Done():
return nil
case cmd, ok := <-ch:
if !ok {
return nil
}
if err := stream.Send(cmd); err != nil {
return err
}
}
}
}
func StartGRPC(port int) error {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
@@ -74,7 +127,7 @@ func StartGRPC(port int) error {
}
s := grpc.NewServer()
pb.RegisterKeyManagerServer(s, &keyManagerServer{})
pb.RegisterVantageServer(s, &vantageServer{})
log.Printf("gRPC server listening on :%d", port)
return s.Serve(lis)
+9 -7
View File
@@ -8,11 +8,13 @@ import (
type Key struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
KeyID string `bson:"key_id" json:"key_id"`
Label string `bson:"label" json:"label"`
PublicKey string `bson:"public_key" json:"public_key"`
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
Source string `bson:"source" json:"source"` // uploaded | generated
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
KeyID string `bson:"key_id" json:"key_id"`
Label string `bson:"label" json:"label"`
PublicKey string `bson:"public_key" json:"public_key"`
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
Source string `bson:"source" json:"source"` // uploaded | generated
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
HasPrivateKey bool `bson:"-" json:"has_private_key"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+11 -10
View File
@@ -8,14 +8,15 @@ import (
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+72
View File
@@ -0,0 +1,72 @@
package services
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
)
func encryptionKey() ([]byte, error) {
raw := os.Getenv("KEY_ENCRYPTION_KEY")
if raw == "" {
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set")
}
key, err := hex.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)")
}
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
}
data, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", fmt.Errorf("invalid ciphertext encoding")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", fmt.Errorf("decryption failed")
}
return string(plaintext), nil
}
+174
View File
@@ -0,0 +1,174 @@
package services
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
)
type commandDispatcher struct {
mu sync.RWMutex
channels map[string]chan *pb.ServerCommand
}
// Dispatcher is the singleton command dispatcher used by both the gRPC server
// and the REST API to push commands to connected agents.
var Dispatcher = &commandDispatcher{
channels: make(map[string]chan *pb.ServerCommand),
}
// Connect registers an agent's command channel. Returns the channel to drain.
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
ch := make(chan *pb.ServerCommand, 16)
d.mu.Lock()
d.channels[serverID] = ch
d.mu.Unlock()
return ch
}
// Disconnect removes the agent's channel on stream close.
func (d *commandDispatcher) Disconnect(serverID string) {
d.mu.Lock()
delete(d.channels, serverID)
d.mu.Unlock()
}
// IsConnected reports whether an agent is currently holding a CommandStream.
func (d *commandDispatcher) IsConnected(serverID string) bool {
d.mu.RLock()
_, ok := d.channels[serverID]
d.mu.RUnlock()
return ok
}
func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) error {
d.mu.RLock()
ch, ok := d.channels[serverID]
d.mu.RUnlock()
if !ok {
return fmt.Errorf("agent for server %s is not connected", serverID)
}
select {
case ch <- cmd:
return nil
default:
return fmt.Errorf("command queue full for server %s", serverID)
}
}
// KeyGenParams carries all options for a generate-key command.
type KeyGenParams struct {
Label string
KeyType string
KeySize int
Passphrase string
Comment string
}
// GetLatestAgentVersion queries the Gitea API for the latest agent/v* release tag
// and returns just the version number (e.g. "1.2.3").
func GetLatestAgentVersion() (string, error) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return "", fmt.Errorf("fetch releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Gitea API returned HTTP %d", resp.StatusCode)
}
var releases []struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return "", fmt.Errorf("decode releases: %w", err)
}
for _, r := range releases {
if strings.HasPrefix(r.TagName, "agent/v") {
return strings.TrimPrefix(r.TagName, "agent/v"), nil
}
}
return "", fmt.Errorf("no agent release found")
}
// DispatchUpdateAgent sends an update command to the named server's agent.
// It fetches the latest version from Gitea and includes the download base URL.
func DispatchUpdateAgent(serverID string) (string, error) {
if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream")
}
version, err := GetLatestAgentVersion()
if err != nil {
return "", fmt.Errorf("get latest version: %w", err)
}
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
cmdID := uuid.New().String()
cmd := &pb.ServerCommand{
CommandId: cmdID,
UpdateAgent: &pb.UpdateAgentCmd{
Version: version,
GiteaBaseURL: "https://" + giteaHost,
},
}
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
return "", err
}
return version, nil
}
// DispatchDeleteKey sends a delete-key command to the named server's agent.
// It is best-effort: if the agent is offline the local files will remain until next connection.
func DispatchDeleteKey(serverID, label string) {
if !Dispatcher.IsConnected(serverID) {
return
}
cmd := &pb.ServerCommand{
CommandId: uuid.New().String(),
DeleteKey: &pb.DeleteKeyCmd{Label: label},
}
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
// Non-fatal: agent will clean up files on next manual intervention or reinstall.
_ = err
}
}
// DispatchGenerateKey sends a generate-key command to the named server's agent.
// Returns the command ID that can be used to correlate the agent's result.
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream")
}
cmdID := uuid.New().String()
cmd := &pb.ServerCommand{
CommandId: cmdID,
GenerateKey: &pb.GenerateKeyCmd{
Label: p.Label,
KeyType: p.KeyType,
KeySize: p.KeySize,
Passphrase: p.Passphrase,
Comment: p.Comment,
},
}
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
return "", err
}
return cmdID, nil
}
+67 -11
View File
@@ -9,8 +9,8 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/keymanager/server/internal/db"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -31,7 +31,11 @@ func computeFingerprint(pubKey string) string {
return "MD5:" + strings.Join(pairs, ":")
}
func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Key, error) {
func setKeyMeta(k *models.Key) {
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
key := &models.Key{
KeyID: uuid.NewString(),
Label: label,
@@ -41,6 +45,13 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
GeneratedByServerID: generatedByServerID,
CreatedAt: time.Now(),
}
if privateKey != "" {
enc, err := encryptPrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("encrypt private key: %w", err)
}
key.PrivateKeyEncrypted = enc
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -48,6 +59,7 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
if err != nil {
return nil, err
}
setKeyMeta(key)
return key, nil
}
@@ -60,10 +72,30 @@ func GetKey(keyID string) (*models.Key, error) {
if err != nil {
return nil, err
}
setKeyMeta(&key)
return &key, nil
}
func ListKeys() ([]models.Key, error) {
func GetPrivateKey(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return "", err
}
if key.PrivateKeyEncrypted == "" {
return "", fmt.Errorf("no private key stored for this key")
}
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
type KeyWithCount struct {
models.Key `bson:",inline"`
AssignedCount int `bson:"-" json:"assigned_count"`
}
func ListKeys() ([]KeyWithCount, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -77,15 +109,39 @@ func ListKeys() ([]models.Key, error) {
if err := cursor.All(ctx, &keys); err != nil {
return nil, err
}
return keys, nil
result := make([]KeyWithCount, 0, len(keys))
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
"key_id": k.KeyID,
"revoked_at": nil,
})
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
}
return result, nil
}
func DeleteKey(keyID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID})
return err
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return err
}
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
return err
}
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil {
return err
}
if key.Source == "generated" && key.GeneratedByServerID != "" {
DispatchDeleteKey(key.GeneratedByServerID, key.Label)
}
return nil
}
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
@@ -198,12 +254,12 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
result := make([]AssignmentWithKey, 0, len(assignments))
for _, a := range assignments {
item := AssignmentWithKey{Assignment: a}
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err == nil {
item.Key = &key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
continue
}
result = append(result, item)
setKeyMeta(&key)
result = append(result, AssignmentWithKey{Assignment: a, Key: &key})
}
return result, nil
}
+9 -4
View File
@@ -6,11 +6,12 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/keymanager/server/internal/db"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -134,14 +135,18 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
return &s, nil
}
func UpdateServerLastSeen(serverID string) error {
func UpdateServerLastSeen(serverID, agentVersion string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
fields := bson.M{"last_seen": now, "status": "active"}
if agentVersion != "" {
fields["agent_version"] = strings.TrimPrefix(agentVersion, "v")
}
_, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID},
bson.M{"$set": bson.M{"last_seen": now, "status": "active"}},
bson.M{"$set": fields},
)
return err
}
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/keymanager/server/internal/db"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+93
View File
@@ -89,6 +89,97 @@ function AssignModal({
);
}
function PrivateKeyCard({ keyId }: { keyId: string }) {
const [revealed, setRevealed] = useState(false);
const [privateKey, setPrivateKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function reveal() {
setLoading(true);
setError(null);
try {
const res = await api.getPrivateKey(keyId);
setPrivateKey(res.private_key);
setRevealed(true);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
function download() {
if (!privateKey) return;
const blob = new Blob([privateKey], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${keyId}.pem`;
a.click();
URL.revokeObjectURL(url);
}
async function copy() {
if (!privateKey) return;
await navigator.clipboard.writeText(privateKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Private Key</CardTitle>
{revealed && (
<div className="flex gap-2">
<button
onClick={copy}
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
<button
onClick={download}
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
Download .pem
</button>
</div>
)}
</CardHeader>
{error && (
<div className="mb-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
{error}
</div>
)}
{!revealed ? (
<div className="flex flex-col items-center gap-3 py-4">
<p className="text-center text-xs text-text-tertiary">
Stored encrypted (AES-256-GCM). Click to decrypt and display.
</p>
<Button variant="secondary" size="sm" loading={loading} onClick={reveal}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.964-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Reveal Private Key
</Button>
</div>
) : (
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{privateKey}
</pre>
</div>
)}
</Card>
);
}
export default function KeyDetailPage() {
const params = useParams();
const router = useRouter();
@@ -252,6 +343,8 @@ export default function KeyDetailPage() {
</pre>
</div>
</Card>
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
</div>
<div className="lg:col-span-2">
+16 -2
View File
@@ -11,9 +11,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const { mutate: upload, isPending, error } = useMutation({
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim()),
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
onClose();
@@ -52,7 +53,20 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder="ssh-ed25519 AAAA..."
rows={4}
rows={3}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Private Key{" "}
<span className="text-text-tertiary font-normal">(optional stored AES-256-GCM encrypted)</span>
</label>
<textarea
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={3}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
+10 -7
View File
@@ -1,10 +1,11 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { AuthProvider } from "@/components/AuthProvider";
import { Sidebar } from "@/components/Sidebar";
export const metadata: Metadata = {
title: "KeyManager",
title: "Vantage",
description: "Self-hosted SSH key management",
};
@@ -17,12 +18,14 @@ export default function RootLayout({
<html lang="en" className="dark">
<body className="bg-background text-text-primary">
<Providers>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</AuthProvider>
</Providers>
</body>
</html>
+249 -5
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, ServerStatus } from "@/lib/api";
import { api, ServerStatus, GenerateKeyOptions } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
@@ -20,12 +20,175 @@ function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
rsa: 4096,
ecdsa: 256,
};
function GenerateKeyModal({
onClose,
onSubmit,
isPending,
}: {
onClose: () => void;
onSubmit: (opts: GenerateKeyOptions) => void;
isPending: boolean;
}) {
const [label, setLabel] = useState("");
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [keySize, setKeySize] = useState<number>(4096);
const [passphrase, setPassphrase] = useState("");
const [comment, setComment] = useState("");
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
setKeyType(t);
if (t !== "ed25519") {
setKeySize(DEFAULT_SIZE[t]);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
label: label || "generated",
key_type: keyType,
key_size: keyType !== "ed25519" ? keySize : undefined,
passphrase: passphrase || undefined,
comment: comment || undefined,
});
}
const sizes = KEY_SIZES[keyType];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
</label>
<input
type="text"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="e.g. server-deploy-key"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-3 gap-2">
{(["ed25519", "rsa", "ecdsa"] as const).map(t => (
<button
key={t}
type="button"
onClick={() => handleKeyTypeChange(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
keyType === t
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t}
</button>
))}
</div>
{keyType === "ed25519" && (
<p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>
)}
{keyType === "rsa" && (
<p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>
)}
{keyType === "ecdsa" && (
<p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>
)}
</div>
{sizes && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
<select
value={keySize}
onChange={e => setKeySize(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{sizes.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
</label>
<input
type="text"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="e.g. user@hostname"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
</label>
<input
type="password"
value={passphrase}
onChange={e => setPassphrase(e.target.value)}
placeholder="Optional passphrase"
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
Generate Key
</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}
export default function ServerDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const serverId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [copiedUpdate, setCopiedUpdate] = useState(false);
const [updateSuccess, setUpdateSuccess] = useState(false);
const { data: server, isLoading, error } = useQuery({
queryKey: ["servers", serverId],
@@ -34,13 +197,28 @@ export default function ServerDetailPage() {
});
const { mutate: generateKey, isPending: isGenerating } = useMutation({
mutationFn: () => api.generateKeyForServer(serverId),
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
onSuccess: () => {
setShowGenerateModal(false);
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
queryClient.invalidateQueries({ queryKey: ["keys"] });
},
});
const { data: latestVersion } = useQuery({
queryKey: ["agent-latest-version"],
queryFn: () => api.getLatestAgentVersion(),
staleTime: 5 * 60_000,
});
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
mutationFn: () => api.updateAgent(serverId),
onSuccess: () => {
setUpdateSuccess(true);
setTimeout(() => setUpdateSuccess(false), 4000);
},
});
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteServer(serverId),
onSuccess: () => {
@@ -69,6 +247,14 @@ export default function ServerDetailPage() {
return (
<div className="p-8">
{showGenerateModal && (
<GenerateKeyModal
onClose={() => setShowGenerateModal(false)}
onSubmit={opts => generateKey(opts)}
isPending={isGenerating}
/>
)}
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
@@ -85,8 +271,7 @@ export default function ServerDetailPage() {
<div className="flex gap-2">
<Button
variant="secondary"
loading={isGenerating}
onClick={() => generateKey()}
onClick={() => setShowGenerateModal(true)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
@@ -115,6 +300,59 @@ export default function ServerDetailPage() {
</div>
</div>
<div className="mb-6">
<Card>
<CardHeader>
<CardTitle>Update Agent</CardTitle>
</CardHeader>
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
<div>
<span className="text-text-secondary">Installed: </span>
<span className="font-mono font-medium text-text-primary">
{server.agent_version ? `v${server.agent_version}` : "unknown"}
</span>
</div>
<div>
<span className="text-text-secondary">Latest: </span>
<span className="font-mono font-medium text-text-primary">
{latestVersion ? `v${latestVersion.version}` : "—"}
</span>
</div>
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && (
<Badge variant="warning">update available</Badge>
)}
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && (
<Badge variant="success">up to date</Badge>
)}
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="primary"
loading={isUpdating}
onClick={() => triggerUpdate()}
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateSuccess ? "Update Sent!" : "Update Agent"}
</Button>
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
<span className="text-accent">$</span>{" "}
<span className="text-text-primary">{api.getUpdateCommand()}</span>
<button
onClick={async () => {
await navigator.clipboard.writeText(api.getUpdateCommand());
setCopiedUpdate(true);
setTimeout(() => setCopiedUpdate(false), 2000);
}}
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
@@ -129,6 +367,12 @@ export default function ServerDetailPage() {
<dt className="text-text-secondary">OS</dt>
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Agent Version</dt>
<dd className="mt-0.5 font-mono text-text-primary">
{server.agent_version ? `v${server.agent_version}` : "unknown"}
</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Last Seen</dt>
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
@@ -176,7 +420,7 @@ export default function ServerDetailPage() {
</Tr>
</Thead>
<Tbody>
{server.keys.map((assignment) => (
{server.keys.filter(a => a.key).map((assignment) => (
<Tr key={assignment.key_id}>
<Td>
<span className="font-medium">{assignment.key.label}</span>
+4 -4
View File
@@ -26,7 +26,7 @@ export default function NewServerPage() {
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">
Generate an install command to register a new server with the KeyManager agent.
Generate an install command to register a new server with the Vantage agent.
</p>
</div>
@@ -39,7 +39,7 @@ export default function NewServerPage() {
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
Click the button below to generate a one-time install command. The command
contains a short-lived token (valid for 1 hour) that registers your server
and installs the KeyManager agent automatically.
and installs the Vantage agent automatically.
</p>
{error && (
@@ -111,8 +111,8 @@ export default function NewServerPage() {
{[
"The install script detects your CPU architecture (amd64 / arm64)",
"Downloads and verifies the latest agent binary from the Gitea release",
"Writes /etc/keymanager/config.yaml with the server ID and token",
"Installs and starts the keymanager-agent systemd service",
"Writes /etc/vantage/config.yaml with the server ID and token",
"Installs and starts the vantage-agent systemd service",
"The agent calls Register() to obtain a persistent auth token",
"The server status changes to active on the first successful sync",
].map((step, i) => (
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
export interface User {
user_id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
authEnabled: boolean;
}
const AuthContext = createContext<AuthContextType>({ user: null, authEnabled: false });
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [authEnabled, setAuthEnabled] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/auth/me", { credentials: "include" })
.then(async (res) => {
if (res.status === 401) {
window.location.href = "/auth/login";
return;
}
const data = await res.json();
if (data.auth_enabled === false) {
setAuthEnabled(false);
} else {
setAuthEnabled(true);
setUser(data as User);
}
setLoading(false);
})
.catch(() => {
// Backend unreachable — don't block the UI
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
return (
<AuthContext.Provider value={{ user, authEnabled }}>
{children}
</AuthContext.Provider>
);
}
+20 -2
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { useAuth } from "@/components/AuthProvider";
interface NavItem {
href: string;
@@ -33,6 +34,7 @@ const navItems: NavItem[] = [
export function Sidebar() {
const pathname = usePathname();
const { user, authEnabled } = useAuth();
return (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
@@ -42,7 +44,7 @@ export function Sidebar() {
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<span className="text-base font-semibold text-text-primary">KeyManager</span>
<span className="text-base font-semibold text-text-primary">Vantage</span>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
@@ -71,7 +73,23 @@ export function Sidebar() {
</nav>
<div className="border-t border-border px-4 py-3">
<p className="text-xs text-text-secondary">KeyManager v1.0</p>
{authEnabled && user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">{user.email}</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">Vantage v1.0</p>
{authEnabled && user && (
<a
href="/auth/logout"
className="text-xs text-text-secondary transition-colors hover:text-danger"
>
Logout
</a>
)}
</div>
</div>
</aside>
);
+34 -4
View File
@@ -8,6 +8,7 @@ export interface Server {
ip_address: string;
os_info: string;
status: ServerStatus;
agent_version?: string;
last_seen: string;
created_at: string;
}
@@ -20,6 +21,7 @@ export interface Key {
fingerprint: string;
source: KeySource;
generated_by_server_id?: string;
has_private_key: boolean;
created_at: string;
assigned_count?: number;
}
@@ -38,6 +40,14 @@ export interface NewServerResponse {
install_command: string;
}
export interface GenerateKeyOptions {
label: string;
key_type: "ed25519" | "rsa" | "ecdsa";
key_size?: number;
passphrase?: string;
comment?: string;
}
export interface KeyWithAssignments extends Key {
assignments: (Assignment & { server: Server })[];
}
@@ -58,6 +68,7 @@ class ApiError extends Error {
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...options?.headers,
@@ -95,8 +106,23 @@ export const api = {
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
},
generateKeyForServer(serverId: string): Promise<{ key_id: string }> {
return request<{ key_id: string }>(`/servers/${serverId}/generate-key`, {
generateKeyForServer(serverId: string, opts: GenerateKeyOptions): Promise<{ command_id: string }> {
return request<{ command_id: string }>(`/servers/${serverId}/generate-key`, {
method: "POST",
body: JSON.stringify(opts),
});
},
getUpdateCommand(): string {
return `curl -fsSL "${window.location.origin}/update" | bash`;
},
getLatestAgentVersion(): Promise<{ version: string }> {
return request<{ version: string }>("/agent/latest-version");
},
updateAgent(serverId: string): Promise<{ message: string; version: string }> {
return request<{ message: string; version: string }>(`/servers/${serverId}/update-agent`, {
method: "POST",
});
},
@@ -110,13 +136,17 @@ export const api = {
return request<KeyWithAssignments>(`/keys/${keyId}`);
},
uploadKey(label: string, public_key: string): Promise<Key> {
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
return request<Key>("/keys", {
method: "POST",
body: JSON.stringify({ label, public_key }),
body: JSON.stringify({ label, public_key, private_key: private_key || undefined }),
});
},
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
},
deleteKey(keyId: string): Promise<void> {
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
},
+21 -13
View File
@@ -3,19 +3,27 @@ import type { NextConfig } from "next";
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${apiUrl}/api/:path*`,
},
{
source: "/install",
destination: `${apiUrl}/install`,
},
];
},
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${apiUrl}/api/:path*`,
},
{
source: "/auth/:path*",
destination: `${apiUrl}/auth/:path*`,
},
{
source: "/install",
destination: `${apiUrl}/install`,
},
{
source: "/update",
destination: `${apiUrl}/update`,
},
];
},
};
export default nextConfig;
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "keymanager-web",
"name": "vantage-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "keymanager-web",
"name": "vantage-web",
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.51.1",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "keymanager-web",
"name": "vantage-web",
"version": "0.1.0",
"private": true,
"scripts": {
View File