Skip to content

Commit 32a8306

Browse files
author
Adel Assakaf
committed
fix: prevent data loss on interrupt by implementing graceful shutdown
1 parent 374fcd1 commit 32a8306

3 files changed

Lines changed: 116 additions & 13 deletions

File tree

cmd/httpx/httpx.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,26 @@ func main() {
7373
c := make(chan os.Signal, 1)
7474
signal.Notify(c, os.Interrupt)
7575
go func() {
76-
for range c {
77-
gologger.Info().Msgf("CTRL+C pressed: Exiting\n")
78-
httpxRunner.Close()
79-
if options.ShouldSaveResume() {
80-
gologger.Info().Msgf("Creating resume file: %s\n", runner.DefaultResumeFile)
81-
err := httpxRunner.SaveResumeConfig()
82-
if err != nil {
83-
gologger.Error().Msgf("Couldn't create resume file: %s\n", err)
84-
}
85-
}
86-
os.Exit(1)
87-
}
76+
// First Ctrl+C: stop dispatching, let in-flight requests finish
77+
<-c
78+
gologger.Info().Msgf("CTRL+C pressed: Exiting\n")
79+
httpxRunner.Interrupt()
80+
// Second Ctrl+C: force exit
81+
<-c
82+
gologger.Info().Msgf("Forcing exit\n")
83+
os.Exit(1)
8884
}()
8985

9086
httpxRunner.RunEnumeration()
87+
88+
if httpxRunner.IsInterrupted() && options.ShouldSaveResume() {
89+
gologger.Info().Msgf("Creating resume file: %s\n", runner.DefaultResumeFile)
90+
err := httpxRunner.SaveResumeConfig()
91+
if err != nil {
92+
gologger.Error().Msgf("Couldn't create resume file: %s\n", err)
93+
}
94+
}
95+
9196
httpxRunner.Close()
9297
}
9398

runner/runner.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,32 @@ type Runner struct {
9797
simHashes gcache.Cache[uint64, struct{}] // Include simHashes for efficient duplicate detection
9898
httpApiEndpoint *Server
9999
authProvider authprovider.AuthProvider
100+
interruptCh chan struct{}
100101
}
101102

102103
func (r *Runner) HTTPX() *httpx.HTTPX {
103104
return r.hp
104105
}
105106

107+
// Interrupt signals the runner to stop dispatching new items.
108+
func (r *Runner) Interrupt() {
109+
select {
110+
case <-r.interruptCh:
111+
default:
112+
close(r.interruptCh)
113+
}
114+
}
115+
116+
// IsInterrupted returns true if the runner was interrupted.
117+
func (r *Runner) IsInterrupted() bool {
118+
select {
119+
case <-r.interruptCh:
120+
return true
121+
default:
122+
return false
123+
}
124+
}
125+
106126
// picked based on try-fail but it seems to close to one it's used https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html#c1992
107127
var hammingDistanceThreshold int = 22
108128

@@ -121,7 +141,8 @@ type pHashUrl struct {
121141
// New creates a new client for running enumeration process.
122142
func New(options *Options) (*Runner, error) {
123143
runner := &Runner{
124-
options: options,
144+
options: options,
145+
interruptCh: make(chan struct{}),
125146
}
126147
var err error
127148
if options.Wappalyzer != nil {
@@ -1402,6 +1423,12 @@ func (r *Runner) RunEnumeration() {
14021423
wg, _ := syncutil.New(syncutil.WithSize(r.options.Threads))
14031424

14041425
processItem := func(k string) error {
1426+
select {
1427+
case <-r.interruptCh:
1428+
return nil
1429+
default:
1430+
}
1431+
14051432
if r.options.resumeCfg != nil {
14061433
r.options.resumeCfg.current = k
14071434
r.options.resumeCfg.currentIndex++
@@ -1447,6 +1474,9 @@ func (r *Runner) RunEnumeration() {
14471474

14481475
if r.options.Stream {
14491476
for item := range streamChan {
1477+
if r.IsInterrupted() {
1478+
break
1479+
}
14501480
_ = processItem(item)
14511481
}
14521482
} else {

runner/runner_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,74 @@ import (
1515
"github.com/stretchr/testify/require"
1616
)
1717

18+
func TestRunner_resumeAfterInterrupt(t *testing.T) {
19+
domains := []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com", "g.com", "h.com", "i.com", "j.com"}
20+
interruptAfter := 4
21+
22+
// --- Full scan (reference): process all domains without interrupt ---
23+
rFull, err := New(&Options{})
24+
require.Nil(t, err, "could not create httpx runner")
25+
rFull.options.resumeCfg = &ResumeCfg{}
26+
var fullOutput []string
27+
for _, d := range domains {
28+
rFull.options.resumeCfg.current = d
29+
rFull.options.resumeCfg.currentIndex++
30+
fullOutput = append(fullOutput, d)
31+
}
32+
33+
// --- Interrupted scan: process items, interrupt after interruptAfter ---
34+
rInt, err := New(&Options{})
35+
require.Nil(t, err, "could not create httpx runner")
36+
rInt.options.resumeCfg = &ResumeCfg{}
37+
var interruptedOutput []string
38+
for _, d := range domains {
39+
// same check as processItem: bail out if interrupted
40+
select {
41+
case <-rInt.interruptCh:
42+
continue
43+
default:
44+
}
45+
46+
rInt.options.resumeCfg.current = d
47+
rInt.options.resumeCfg.currentIndex++
48+
interruptedOutput = append(interruptedOutput, d)
49+
50+
if len(interruptedOutput) == interruptAfter {
51+
rInt.Interrupt()
52+
}
53+
}
54+
55+
// simulate SaveResumeConfig: save the index after interrupt
56+
savedIndex := rInt.options.resumeCfg.currentIndex
57+
58+
// the saved index must equal exactly the number of items that were processed
59+
require.Equal(t, interruptAfter, savedIndex, "resume index should equal number of completed items")
60+
// every domain before the index must be in the interrupted output
61+
require.Equal(t, domains[:interruptAfter], interruptedOutput, "interrupted output should contain exactly the first N domains")
62+
63+
// --- Resumed scan: load saved index, skip already-processed items ---
64+
rRes, err := New(&Options{})
65+
require.Nil(t, err, "could not create httpx runner")
66+
rRes.options.resumeCfg = &ResumeCfg{Index: savedIndex}
67+
var resumedOutput []string
68+
for _, d := range domains {
69+
// same resume-skip logic as processItem
70+
rRes.options.resumeCfg.current = d
71+
rRes.options.resumeCfg.currentIndex++
72+
if rRes.options.resumeCfg.currentIndex <= rRes.options.resumeCfg.Index {
73+
continue
74+
}
75+
resumedOutput = append(resumedOutput, d)
76+
}
77+
78+
// every domain after the index must be in the resumed output
79+
require.Equal(t, domains[interruptAfter:], resumedOutput, "resumed output should contain exactly the remaining domains")
80+
81+
// union of interrupted + resumed must equal the full scan
82+
combined := append(interruptedOutput, resumedOutput...)
83+
require.Equal(t, fullOutput, combined, "interrupted + resumed should equal full scan")
84+
}
85+
1886
func TestRunner_domain_targets(t *testing.T) {
1987
options := &Options{}
2088
r, err := New(options)

0 commit comments

Comments
 (0)