Skip to content

Commit a4df370

Browse files
committed
feat: add subdomain import + fix nuclei progress tracking
- Add subdomain import feature: POST /api/subdomains/import with modal UI on Subdomains page. Strips URLs, extracts root domain via publicsuffix (eTLD+1), groups by domain, batch inserts via existing BatchInsertSubdomains. - Add ParseSubdomainAndRoot utility using golang.org/x/net/publicsuffix - Fix nuclei global template scan not showing progress: RunScanInProcess now auto-creates phase tracking when total_phases is 0, same as executeScan does
1 parent d4216e5 commit a4df370

7 files changed

Lines changed: 209 additions & 0 deletions

File tree

internal/api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ func SetupAPI() *gin.Engine {
491491
apiGroup.POST("/subdomains/cnames/retry", apiRetryCnames)
492492
apiGroup.GET("/subdomains/cnames/progress", apiRetryCnamesProgress)
493493
apiGroup.POST("/subdomains/nuclei/run", apiRunGlobalNuclei)
494+
apiGroup.POST("/subdomains/import", apiImportSubdomains)
494495
apiGroup.GET("/scans", apiListScans)
495496
apiGroup.GET("/scans/:id/results/summary", apiScanResultsSummary)
496497
apiGroup.GET("/scans/:id/results/files", apiScanResultFiles)

internal/api/scan_runner.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ func RunScanInProcess(scanID, scanType, target string, fn func() error) {
143143

144144
_ = db.UpdateScanResult(scanID, status, "")
145145

146+
// Ensure every in-process scan shows progress in the dashboard.
147+
// Without this, one-shot scans like global nuclei / subdomain_run
148+
// appear with 0 phases and no progress bar.
149+
record, _ := db.GetScan(scanID)
150+
if record != nil && record.TotalPhases == 0 {
151+
scanLabel := scanType
152+
phaseFailed := status == "failed"
153+
_ = db.AppendScanPhase(scanID, scanLabel+" scan", phaseFailed)
154+
_ = db.UpdateScanProgress(scanID, &db.ScanProgress{
155+
CurrentPhase: 1,
156+
TotalPhases: 1,
157+
PhaseName: scanLabel + " scan",
158+
CompletedPhases: []string{scanLabel + " scan"},
159+
})
160+
}
161+
146162
ScansMutex.Lock()
147163
delete(ActiveScans, scanID)
148164
ScansMutex.Unlock()

internal/api/ui/app.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,3 +821,15 @@ async function submitNucleiModal() {
821821
return callPageMethod('OpsToolsPage', 'submitNucleiModal');
822822
}
823823

824+
function promptImportSubdomains() {
825+
return callPageMethod('OpsToolsPage', 'promptImportSubdomains');
826+
}
827+
828+
function closeImportSubdomainsModal() {
829+
return callPageMethod('OpsToolsPage', 'closeImportSubdomainsModal');
830+
}
831+
832+
async function submitImportSubdomains() {
833+
return callPageMethod('OpsToolsPage', 'submitImportSubdomains');
834+
}
835+

internal/api/ui/index.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ <h1 class="view-title">Subdomains</h1>
324324
<button id="copy-all-subs-btn" class="btn btn-ghost" style="font-size:12px;padding:7px 16px;gap:6px;display:flex;align-items:center">
325325
📋 Copy All Subdomains
326326
</button>
327+
<button id="import-subs-btn" class="btn btn-primary" style="font-size:12px;padding:7px 16px;gap:6px;display:flex;align-items:center" onclick="promptImportSubdomains()">
328+
📥 Import Subdomains
329+
</button>
327330
</div>
328331
</div>
329332
<div class="filter-bar" id="filter-bar-subdomains" style="display:flex;gap:12px;flex-wrap:wrap">
@@ -710,6 +713,25 @@ <h2 class="auth-title" style="margin-bottom: 8px;">Run Global Nuclei Scan</h2>
710713
</div>
711714
</div>
712715

716+
<!-- Subdomain Import Modal -->
717+
<div id="subdomain-import-modal" class="auth-gate" style="display:none; z-index: 1000;" aria-modal="true" role="dialog">
718+
<div class="auth-card" style="max-width: 600px; width: 90%; max-height: 85vh; display: flex; flex-direction: column;">
719+
<h2 class="auth-title" style="margin-bottom: 8px;">Import Subdomains</h2>
720+
<p class="auth-sub" style="margin-bottom: 20px;">Paste a list of subdomains or URLs (one per line). Root domains are detected automatically.</p>
721+
722+
<div style="display: flex; flex-direction: column; flex: 1; min-height: 0;">
723+
<textarea id="subdomain-import-input" class="search-input auth-input"
724+
style="flex: 1; min-height: 250px; overflow-y: auto; font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1.5; padding: 12px; resize: vertical; background: #010409;"
725+
placeholder="admin.target.com&#10;https://dev.target.com/path?q=1&#10;api.staging.target.com:8080&#10;https://another-domain.net&#10;# lines starting with # are ignored"></textarea>
726+
</div>
727+
728+
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; flex-shrink: 0;">
729+
<button type="button" class="btn btn-ghost" onclick="closeImportSubdomainsModal()">Cancel</button>
730+
<button type="button" class="btn btn-primary" onclick="submitImportSubdomains()" style="background:var(--accent-cyan-dim);color:var(--accent-cyan);border-color:rgba(34,211,238,0.3)">📥 Import</button>
731+
</div>
732+
</div>
733+
</div>
734+
713735
<!-- Toast notifications -->
714736
<div class="toast-container" id="toast-container"></div>
715737

internal/api/ui/pages/ops-tools.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,37 @@
174174
}
175175
}
176176

177+
function promptImportSubdomains() {
178+
document.getElementById('subdomain-import-input').value = '';
179+
document.getElementById('subdomain-import-modal').style.display = 'flex';
180+
}
181+
182+
function closeImportSubdomainsModal() {
183+
document.getElementById('subdomain-import-modal').style.display = 'none';
184+
}
185+
186+
async function submitImportSubdomains() {
187+
const lines = document.getElementById('subdomain-import-input').value.trim();
188+
if (!lines) {
189+
window.showToast('error', 'Error', 'No subdomains provided');
190+
return;
191+
}
192+
try {
193+
const data = await window.apiPost('/api/subdomains/import', { lines });
194+
closeImportSubdomainsModal();
195+
if (data.imported > 0) {
196+
window.showToast('success', 'Imported', `${data.imported} subdomains across ${data.domains} domains` + (data.skipped ? ` (${data.skipped} skipped)` : ''));
197+
if (typeof window.loadSubdomains === 'function') {
198+
window.loadSubdomains(1, '');
199+
}
200+
} else {
201+
window.showToast('warning', 'Nothing imported', data.message || 'No valid domains found');
202+
}
203+
} catch (err) {
204+
window.showToast('error', 'Error', err.message);
205+
}
206+
}
207+
177208
window.OpsToolsPage = {
178209
exportScanResultsCSV,
179210
generateScanReport,
@@ -182,11 +213,17 @@
182213
promptRunGlobalNuclei,
183214
closeNucleiModal,
184215
submitNucleiModal,
216+
promptImportSubdomains,
217+
closeImportSubdomainsModal,
218+
submitImportSubdomains,
185219
};
186220

187221
window.promptRetryCnames = promptRetryCnames;
188222
window.promptRunGlobalNuclei = promptRunGlobalNuclei;
189223
window.closeNucleiModal = closeNucleiModal;
190224
window.submitNucleiModal = submitNucleiModal;
225+
window.promptImportSubdomains = promptImportSubdomains;
226+
window.closeImportSubdomainsModal = closeImportSubdomainsModal;
227+
window.submitImportSubdomains = submitImportSubdomains;
191228
setTimeout(startCnamesProgressPolling, 1000);
192229
})();

internal/api/ui_api.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,78 @@ func apiRunGlobalNuclei(c *gin.Context) {
630630
})
631631
}
632632

633+
// POST /api/subdomains/import
634+
func apiImportSubdomains(c *gin.Context) {
635+
_ = db.Init()
636+
_ = db.EnsureSchema()
637+
638+
var req struct {
639+
Lines string `json:"lines"`
640+
}
641+
if err := c.ShouldBindJSON(&req); err != nil {
642+
c.JSON(400, gin.H{"error": "invalid payload"})
643+
return
644+
}
645+
646+
rawLines := strings.Split(strings.TrimSpace(req.Lines), "\n")
647+
if len(rawLines) == 0 || (len(rawLines) == 1 && rawLines[0] == "") {
648+
c.JSON(400, gin.H{"error": "no lines provided"})
649+
return
650+
}
651+
652+
// Group by root domain
653+
byDomain := map[string][]string{}
654+
skipped := 0
655+
for _, line := range rawLines {
656+
line = strings.TrimSpace(line)
657+
if line == "" || strings.HasPrefix(line, "#") {
658+
continue
659+
}
660+
root, sub, ok := utils.ParseSubdomainAndRoot(line)
661+
if !ok {
662+
skipped++
663+
continue
664+
}
665+
byDomain[root] = append(byDomain[root], sub)
666+
}
667+
668+
if len(byDomain) == 0 {
669+
c.JSON(200, gin.H{"imported": 0, "skipped": skipped, "domains": 0, "message": "No valid domains found in input"})
670+
return
671+
}
672+
673+
total := 0
674+
for domain, subs := range byDomain {
675+
deduped := uniqueStrings(subs)
676+
if err := db.BatchInsertSubdomains(domain, deduped, false); err != nil {
677+
log.Printf("[subdomains/import] failed to insert %d subs for %s: %v", len(deduped), domain, err)
678+
continue
679+
}
680+
total += len(deduped)
681+
}
682+
683+
c.JSON(200, gin.H{
684+
"imported": total,
685+
"skipped": skipped,
686+
"domains": len(byDomain),
687+
"message": fmt.Sprintf("Imported %d subdomains across %d domains", total, len(byDomain)),
688+
})
689+
}
690+
691+
func uniqueStrings(in []string) []string {
692+
seen := map[string]bool{}
693+
out := make([]string, 0, len(in))
694+
for _, s := range in {
695+
s = strings.TrimSpace(s)
696+
if s == "" || seen[s] {
697+
continue
698+
}
699+
seen[s] = true
700+
out = append(out, s)
701+
}
702+
return out
703+
}
704+
633705
// DELETE /api/domains/:domain — remove domain row, subdomains, related scans/artifacts, monitor rows, subdomain monitor target; R2 cleanup for those scans.
634706
func apiDeleteDomain(c *gin.Context) {
635707
_ = db.Init()

internal/utils/domain.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package utils
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
7+
"golang.org/x/net/publicsuffix"
8+
)
9+
10+
// ParseSubdomainAndRoot parses a raw input (URL or bare hostname) and returns
11+
// (rootDomain, subdomain, ok). The root domain is the eTLD+1 (e.g. "example.com").
12+
// The subdomain is the full hostname (e.g. "admin.staging.example.com").
13+
// Returns ok=false if the input can't be parsed as a valid domain.
14+
func ParseSubdomainAndRoot(raw string) (root, sub string, ok bool) {
15+
raw = strings.TrimSpace(raw)
16+
if raw == "" {
17+
return "", "", false
18+
}
19+
20+
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
21+
raw = "http://" + raw
22+
}
23+
24+
u, err := url.Parse(raw)
25+
if err != nil || u.Host == "" {
26+
return "", "", false
27+
}
28+
29+
host := u.Hostname()
30+
if host == "" {
31+
return "", "", false
32+
}
33+
34+
eTLD, icann := publicsuffix.PublicSuffix(host)
35+
if !icann {
36+
return "", "", false
37+
}
38+
if eTLD == host {
39+
return "", "", false
40+
}
41+
42+
// Compute eTLD+1 (root domain)
43+
withoutTLD := strings.TrimSuffix(host, "."+eTLD)
44+
parts := strings.Split(withoutTLD, ".")
45+
root = parts[len(parts)-1] + "." + eTLD
46+
sub = host
47+
48+
return root, sub, true
49+
}

0 commit comments

Comments
 (0)