domsniper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/package.json +72 -0
- package/src/app.tsx +2062 -0
- package/src/completions.ts +65 -0
- package/src/core/db.ts +1313 -0
- package/src/core/features/asn-lookup.ts +91 -0
- package/src/core/features/backlinks.ts +83 -0
- package/src/core/features/blacklist-check.ts +67 -0
- package/src/core/features/cert-transparency.ts +87 -0
- package/src/core/features/config.ts +81 -0
- package/src/core/features/cors-check.ts +90 -0
- package/src/core/features/dns-details.ts +27 -0
- package/src/core/features/domain-age.ts +33 -0
- package/src/core/features/domain-suggest.ts +87 -0
- package/src/core/features/drop-catch.ts +159 -0
- package/src/core/features/email-security.ts +112 -0
- package/src/core/features/expiring-feed.ts +160 -0
- package/src/core/features/export.ts +74 -0
- package/src/core/features/filter.ts +96 -0
- package/src/core/features/http-probe.ts +46 -0
- package/src/core/features/marketplace.ts +69 -0
- package/src/core/features/path-scanner.ts +123 -0
- package/src/core/features/port-scanner.ts +132 -0
- package/src/core/features/portfolio-bulk.ts +125 -0
- package/src/core/features/portfolio-monitor.ts +214 -0
- package/src/core/features/portfolio.ts +98 -0
- package/src/core/features/price-compare.ts +39 -0
- package/src/core/features/rdap.ts +128 -0
- package/src/core/features/reverse-ip.ts +73 -0
- package/src/core/features/s3-export.ts +99 -0
- package/src/core/features/scoring.ts +121 -0
- package/src/core/features/security-headers.ts +162 -0
- package/src/core/features/session.ts +74 -0
- package/src/core/features/snipe.ts +264 -0
- package/src/core/features/social-check.ts +81 -0
- package/src/core/features/ssl-check.ts +88 -0
- package/src/core/features/subdomain-discovery.ts +53 -0
- package/src/core/features/takeover-detect.ts +143 -0
- package/src/core/features/tech-stack.ts +135 -0
- package/src/core/features/tld-expand.ts +43 -0
- package/src/core/features/variations.ts +134 -0
- package/src/core/features/version-check.ts +58 -0
- package/src/core/features/waf-detect.ts +171 -0
- package/src/core/features/watch.ts +120 -0
- package/src/core/features/wayback.ts +64 -0
- package/src/core/features/webhooks.ts +126 -0
- package/src/core/features/whois-history.ts +99 -0
- package/src/core/features/zone-transfer.ts +75 -0
- package/src/core/index.ts +50 -0
- package/src/core/paths.ts +9 -0
- package/src/core/registrar.ts +413 -0
- package/src/core/theme.ts +140 -0
- package/src/core/types.ts +143 -0
- package/src/core/validate.ts +58 -0
- package/src/core/whois.ts +265 -0
- package/src/index.tsx +1888 -0
- package/src/market-client.ts +186 -0
- package/src/proxy/ca.ts +116 -0
- package/src/proxy/db.ts +175 -0
- package/src/proxy/server.ts +155 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
|
|
3
|
+
export interface MarketplaceListing {
|
|
4
|
+
source: string;
|
|
5
|
+
listed: boolean;
|
|
6
|
+
price: number | null;
|
|
7
|
+
currency: string;
|
|
8
|
+
url: string | null;
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function checkEstibot(domain: string): Promise<MarketplaceListing> {
|
|
13
|
+
try {
|
|
14
|
+
const resp = await fetch(
|
|
15
|
+
`https://www.estibot.com/api/v1/appraisal?domain=${encodeURIComponent(domain)}`,
|
|
16
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
17
|
+
);
|
|
18
|
+
if (!resp.ok) {
|
|
19
|
+
return { source: "estibot", listed: false, price: null, currency: "USD", url: null, error: `HTTP ${resp.status}` };
|
|
20
|
+
}
|
|
21
|
+
const text = await resp.text();
|
|
22
|
+
// Estibot returns estimated value, not a listing
|
|
23
|
+
const match = text.match(/"appraised_value"\s*:\s*(\d+)/);
|
|
24
|
+
const value = match?.[1] ? parseInt(match[1], 10) : null;
|
|
25
|
+
return {
|
|
26
|
+
source: "estibot",
|
|
27
|
+
listed: false,
|
|
28
|
+
price: value,
|
|
29
|
+
currency: "USD",
|
|
30
|
+
url: `https://www.estibot.com/appraisal/${encodeURIComponent(domain)}`,
|
|
31
|
+
error: null,
|
|
32
|
+
};
|
|
33
|
+
} catch (err: unknown) {
|
|
34
|
+
return { source: "estibot", listed: false, price: null, currency: "USD", url: null, error: err instanceof Error ? err.message : "Failed" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function checkSedo(domain: string): Promise<MarketplaceListing> {
|
|
39
|
+
try {
|
|
40
|
+
const resp = await fetch(
|
|
41
|
+
`https://sedo.com/search/searchresult.php?keyword=${encodeURIComponent(domain)}&trackingid=domain-sniper`,
|
|
42
|
+
{ signal: AbortSignal.timeout(8000), headers: { "User-Agent": "DomainSniper/2.0" } }
|
|
43
|
+
);
|
|
44
|
+
const listed = resp.ok;
|
|
45
|
+
return {
|
|
46
|
+
source: "sedo",
|
|
47
|
+
listed,
|
|
48
|
+
price: null,
|
|
49
|
+
currency: "USD",
|
|
50
|
+
url: `https://sedo.com/search/details/?domain=${encodeURIComponent(domain)}`,
|
|
51
|
+
error: null,
|
|
52
|
+
};
|
|
53
|
+
} catch (err: unknown) {
|
|
54
|
+
return { source: "sedo", listed: false, price: null, currency: "USD", url: null, error: err instanceof Error ? err.message : "Failed" };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function checkMarketplaces(domain: string): Promise<MarketplaceListing[]> {
|
|
59
|
+
assertValidDomain(domain);
|
|
60
|
+
const results = await Promise.allSettled([
|
|
61
|
+
checkEstibot(domain),
|
|
62
|
+
checkSedo(domain),
|
|
63
|
+
]);
|
|
64
|
+
return results.map((r) =>
|
|
65
|
+
r.status === "fulfilled"
|
|
66
|
+
? r.value
|
|
67
|
+
: { source: "unknown", listed: false, price: null, currency: "USD", url: null, error: r.reason?.message || "Failed" }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
|
|
3
|
+
export interface PathScanResult {
|
|
4
|
+
domain: string;
|
|
5
|
+
findings: PathFinding[];
|
|
6
|
+
scannedPaths: number;
|
|
7
|
+
error: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PathFinding {
|
|
11
|
+
path: string;
|
|
12
|
+
status: number;
|
|
13
|
+
severity: "critical" | "high" | "medium" | "low" | "info";
|
|
14
|
+
description: string;
|
|
15
|
+
size: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SENSITIVE_PATHS: { path: string; severity: "critical" | "high" | "medium" | "low" | "info"; description: string }[] = [
|
|
19
|
+
// Critical — secrets and credentials
|
|
20
|
+
{ path: "/.env", severity: "critical", description: "Environment file — may contain API keys and passwords" },
|
|
21
|
+
{ path: "/.env.local", severity: "critical", description: "Local environment file" },
|
|
22
|
+
{ path: "/.env.production", severity: "critical", description: "Production environment file" },
|
|
23
|
+
{ path: "/.git/config", severity: "critical", description: "Git config exposed — repo may be cloneable" },
|
|
24
|
+
{ path: "/.git/HEAD", severity: "critical", description: "Git HEAD exposed — confirms .git directory" },
|
|
25
|
+
{ path: "/wp-config.php.bak", severity: "critical", description: "WordPress config backup with DB credentials" },
|
|
26
|
+
{ path: "/.aws/credentials", severity: "critical", description: "AWS credentials file" },
|
|
27
|
+
{ path: "/config.json", severity: "high", description: "Configuration file possibly containing secrets" },
|
|
28
|
+
{ path: "/config.yaml", severity: "high", description: "YAML configuration file" },
|
|
29
|
+
// High — admin and debug
|
|
30
|
+
{ path: "/wp-admin/", severity: "high", description: "WordPress admin panel" },
|
|
31
|
+
{ path: "/admin/", severity: "high", description: "Admin panel" },
|
|
32
|
+
{ path: "/phpinfo.php", severity: "high", description: "PHP info page — exposes server configuration" },
|
|
33
|
+
{ path: "/server-status", severity: "high", description: "Apache server status page" },
|
|
34
|
+
{ path: "/server-info", severity: "high", description: "Apache server info page" },
|
|
35
|
+
{ path: "/.htpasswd", severity: "high", description: "htpasswd file with hashed credentials" },
|
|
36
|
+
{ path: "/debug/", severity: "high", description: "Debug endpoint" },
|
|
37
|
+
{ path: "/_debug/", severity: "high", description: "Debug endpoint" },
|
|
38
|
+
{ path: "/actuator", severity: "high", description: "Spring Boot actuator endpoints" },
|
|
39
|
+
{ path: "/actuator/health", severity: "medium", description: "Spring Boot health endpoint" },
|
|
40
|
+
{ path: "/actuator/env", severity: "critical", description: "Spring Boot environment — may expose secrets" },
|
|
41
|
+
// Medium — information disclosure
|
|
42
|
+
{ path: "/robots.txt", severity: "info", description: "Robots.txt — may reveal hidden paths" },
|
|
43
|
+
{ path: "/sitemap.xml", severity: "info", description: "Sitemap — shows site structure" },
|
|
44
|
+
{ path: "/.DS_Store", severity: "medium", description: "macOS directory metadata — leaks filenames" },
|
|
45
|
+
{ path: "/crossdomain.xml", severity: "medium", description: "Flash crossdomain policy" },
|
|
46
|
+
{ path: "/security.txt", severity: "info", description: "Security contact information" },
|
|
47
|
+
{ path: "/.well-known/security.txt", severity: "info", description: "Security contact (standard location)" },
|
|
48
|
+
{ path: "/package.json", severity: "medium", description: "Node.js package manifest — shows dependencies" },
|
|
49
|
+
{ path: "/composer.json", severity: "medium", description: "PHP Composer manifest" },
|
|
50
|
+
{ path: "/Gemfile", severity: "medium", description: "Ruby Gemfile" },
|
|
51
|
+
{ path: "/wp-json/", severity: "low", description: "WordPress REST API" },
|
|
52
|
+
{ path: "/api/", severity: "info", description: "API endpoint" },
|
|
53
|
+
{ path: "/graphql", severity: "medium", description: "GraphQL endpoint — may allow introspection" },
|
|
54
|
+
{ path: "/swagger.json", severity: "medium", description: "Swagger/OpenAPI spec" },
|
|
55
|
+
{ path: "/api-docs", severity: "medium", description: "API documentation endpoint" },
|
|
56
|
+
{ path: "/.well-known/openid-configuration", severity: "info", description: "OpenID Connect configuration" },
|
|
57
|
+
{ path: "/backup.sql", severity: "critical", description: "SQL database backup" },
|
|
58
|
+
{ path: "/dump.sql", severity: "critical", description: "SQL database dump" },
|
|
59
|
+
{ path: "/db.sql", severity: "critical", description: "SQL database file" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export async function scanPaths(
|
|
63
|
+
domain: string,
|
|
64
|
+
paths: typeof SENSITIVE_PATHS = SENSITIVE_PATHS,
|
|
65
|
+
concurrency: number = 5
|
|
66
|
+
): Promise<PathScanResult> {
|
|
67
|
+
assertValidDomain(domain);
|
|
68
|
+
|
|
69
|
+
const result: PathScanResult = {
|
|
70
|
+
domain, findings: [], scannedPaths: 0, error: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
for (let i = 0; i < paths.length; i += concurrency) {
|
|
75
|
+
const batch = paths.slice(i, i + concurrency);
|
|
76
|
+
const batchResults = await Promise.all(
|
|
77
|
+
batch.map(async (p) => {
|
|
78
|
+
result.scannedPaths++;
|
|
79
|
+
try {
|
|
80
|
+
const resp = await fetch(`https://${domain}${p.path}`, {
|
|
81
|
+
method: "HEAD",
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: AbortSignal.timeout(5000),
|
|
84
|
+
headers: { "User-Agent": "DomainSniper/2.0" },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 200 = found, 403 = exists but forbidden (still interesting)
|
|
88
|
+
if (resp.status === 200 || resp.status === 403) {
|
|
89
|
+
const size = resp.headers.get("content-length");
|
|
90
|
+
return {
|
|
91
|
+
path: p.path,
|
|
92
|
+
status: resp.status,
|
|
93
|
+
severity: p.severity,
|
|
94
|
+
description: resp.status === 403
|
|
95
|
+
? `${p.description} (403 Forbidden — exists but protected)`
|
|
96
|
+
: p.description,
|
|
97
|
+
size: size ? parseInt(size, 10) : null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
for (const r of batchResults) {
|
|
108
|
+
if (r) result.findings.push(r);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Sort by severity
|
|
113
|
+
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
114
|
+
result.findings.sort((a, b) => (severityOrder[a.severity] ?? 5) - (severityOrder[b.severity] ?? 5));
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
} catch (err: unknown) {
|
|
118
|
+
result.error = err instanceof Error ? err.message : "Path scan failed";
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export { SENSITIVE_PATHS };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
import { connect } from "net";
|
|
3
|
+
|
|
4
|
+
export interface PortResult {
|
|
5
|
+
port: number;
|
|
6
|
+
service: string;
|
|
7
|
+
open: boolean;
|
|
8
|
+
banner: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PortScanResult {
|
|
12
|
+
domain: string;
|
|
13
|
+
ip: string | null;
|
|
14
|
+
openPorts: PortResult[];
|
|
15
|
+
closedCount: number;
|
|
16
|
+
scanTime: number;
|
|
17
|
+
error: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const COMMON_PORTS: { port: number; service: string }[] = [
|
|
21
|
+
{ port: 21, service: "FTP" },
|
|
22
|
+
{ port: 22, service: "SSH" },
|
|
23
|
+
{ port: 25, service: "SMTP" },
|
|
24
|
+
{ port: 53, service: "DNS" },
|
|
25
|
+
{ port: 80, service: "HTTP" },
|
|
26
|
+
{ port: 110, service: "POP3" },
|
|
27
|
+
{ port: 143, service: "IMAP" },
|
|
28
|
+
{ port: 443, service: "HTTPS" },
|
|
29
|
+
{ port: 465, service: "SMTPS" },
|
|
30
|
+
{ port: 587, service: "Submission" },
|
|
31
|
+
{ port: 993, service: "IMAPS" },
|
|
32
|
+
{ port: 995, service: "POP3S" },
|
|
33
|
+
{ port: 3000, service: "Dev Server" },
|
|
34
|
+
{ port: 3306, service: "MySQL" },
|
|
35
|
+
{ port: 5432, service: "PostgreSQL" },
|
|
36
|
+
{ port: 6379, service: "Redis" },
|
|
37
|
+
{ port: 8080, service: "HTTP Alt" },
|
|
38
|
+
{ port: 8443, service: "HTTPS Alt" },
|
|
39
|
+
{ port: 27017, service: "MongoDB" },
|
|
40
|
+
{ port: 9200, service: "Elasticsearch" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function checkPort(host: string, port: number, timeoutMs: number = 3000): Promise<{ open: boolean; banner: string | null }> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const socket = connect({ host, port });
|
|
46
|
+
let banner: string | null = null;
|
|
47
|
+
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
socket.destroy();
|
|
50
|
+
resolve({ open: false, banner: null });
|
|
51
|
+
}, timeoutMs);
|
|
52
|
+
|
|
53
|
+
socket.on("connect", () => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
// Try to grab a banner (wait briefly for data)
|
|
56
|
+
const bannerTimer = setTimeout(() => {
|
|
57
|
+
socket.destroy();
|
|
58
|
+
resolve({ open: true, banner });
|
|
59
|
+
}, 1500);
|
|
60
|
+
|
|
61
|
+
socket.once("data", (data) => {
|
|
62
|
+
clearTimeout(bannerTimer);
|
|
63
|
+
banner = data.toString("utf-8").trim().slice(0, 200);
|
|
64
|
+
socket.destroy();
|
|
65
|
+
resolve({ open: true, banner });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
socket.on("error", () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
socket.destroy();
|
|
72
|
+
resolve({ open: false, banner: null });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function scanPorts(
|
|
78
|
+
domain: string,
|
|
79
|
+
ports: { port: number; service: string }[] = COMMON_PORTS,
|
|
80
|
+
concurrency: number = 10
|
|
81
|
+
): Promise<PortScanResult> {
|
|
82
|
+
assertValidDomain(domain);
|
|
83
|
+
const startTime = Date.now();
|
|
84
|
+
|
|
85
|
+
const result: PortScanResult = {
|
|
86
|
+
domain,
|
|
87
|
+
ip: null,
|
|
88
|
+
openPorts: [],
|
|
89
|
+
closedCount: 0,
|
|
90
|
+
scanTime: 0,
|
|
91
|
+
error: null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
// Resolve domain to IP first
|
|
96
|
+
const { execFile } = await import("child_process");
|
|
97
|
+
const { promisify } = await import("util");
|
|
98
|
+
const execFileAsync = promisify(execFile);
|
|
99
|
+
try {
|
|
100
|
+
const { stdout } = await execFileAsync("dig", ["+short", domain, "A"], { timeout: 5000 });
|
|
101
|
+
result.ip = stdout.trim().split("\n")[0] || null;
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
const host = result.ip || domain;
|
|
105
|
+
|
|
106
|
+
// Scan in batches
|
|
107
|
+
for (let i = 0; i < ports.length; i += concurrency) {
|
|
108
|
+
const batch = ports.slice(i, i + concurrency);
|
|
109
|
+
const batchResults = await Promise.all(
|
|
110
|
+
batch.map(async (p) => {
|
|
111
|
+
const { open, banner } = await checkPort(host, p.port);
|
|
112
|
+
return { port: p.port, service: p.service, open, banner };
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
for (const r of batchResults) {
|
|
117
|
+
if (r.open) {
|
|
118
|
+
result.openPorts.push(r);
|
|
119
|
+
} else {
|
|
120
|
+
result.closedCount++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (err: unknown) {
|
|
125
|
+
result.error = err instanceof Error ? err.message : "Port scan failed";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
result.scanTime = Date.now() - startTime;
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { COMMON_PORTS };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPortfolioDomains,
|
|
3
|
+
updatePortfolioStatus,
|
|
4
|
+
updatePortfolioCategory,
|
|
5
|
+
addTransaction,
|
|
6
|
+
removePortfolioDomain,
|
|
7
|
+
type PortfolioStatus,
|
|
8
|
+
type TransactionType,
|
|
9
|
+
type DbPortfolioDomain,
|
|
10
|
+
} from "../db.js";
|
|
11
|
+
import { writeFileSync } from "fs";
|
|
12
|
+
import { getTaxExportData, getPortfolioPnL, getDomainPnL, getTransactions } from "../db.js";
|
|
13
|
+
|
|
14
|
+
export interface BulkResult {
|
|
15
|
+
total: number;
|
|
16
|
+
success: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
errors: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bulkUpdateStatus(domains: string[], status: PortfolioStatus): BulkResult {
|
|
22
|
+
const result: BulkResult = { total: domains.length, success: 0, failed: 0, errors: [] };
|
|
23
|
+
for (const domain of domains) {
|
|
24
|
+
try {
|
|
25
|
+
updatePortfolioStatus(domain, status);
|
|
26
|
+
result.success++;
|
|
27
|
+
} catch (err: unknown) {
|
|
28
|
+
result.failed++;
|
|
29
|
+
result.errors.push(`${domain}: ${err instanceof Error ? err.message : "failed"}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function bulkUpdateCategory(domains: string[], category: string): BulkResult {
|
|
36
|
+
const result: BulkResult = { total: domains.length, success: 0, failed: 0, errors: [] };
|
|
37
|
+
for (const domain of domains) {
|
|
38
|
+
try {
|
|
39
|
+
updatePortfolioCategory(domain, category);
|
|
40
|
+
result.success++;
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
result.failed++;
|
|
43
|
+
result.errors.push(`${domain}: ${err instanceof Error ? err.message : "failed"}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bulkAddTransaction(
|
|
50
|
+
domains: string[],
|
|
51
|
+
type: TransactionType,
|
|
52
|
+
amount: number,
|
|
53
|
+
description: string = "",
|
|
54
|
+
date?: string
|
|
55
|
+
): BulkResult {
|
|
56
|
+
const result: BulkResult = { total: domains.length, success: 0, failed: 0, errors: [] };
|
|
57
|
+
for (const domain of domains) {
|
|
58
|
+
try {
|
|
59
|
+
addTransaction(domain, type, amount, description, date);
|
|
60
|
+
result.success++;
|
|
61
|
+
} catch (err: unknown) {
|
|
62
|
+
result.failed++;
|
|
63
|
+
result.errors.push(`${domain}: ${err instanceof Error ? err.message : "failed"}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function bulkRemove(domains: string[]): BulkResult {
|
|
70
|
+
const result: BulkResult = { total: domains.length, success: 0, failed: 0, errors: [] };
|
|
71
|
+
for (const domain of domains) {
|
|
72
|
+
try {
|
|
73
|
+
removePortfolioDomain(domain);
|
|
74
|
+
result.success++;
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
result.failed++;
|
|
77
|
+
result.errors.push(`${domain}: ${err instanceof Error ? err.message : "failed"}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Export ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export function exportPortfolioCSV(filePath: string): string {
|
|
86
|
+
const domains = getPortfolioDomains();
|
|
87
|
+
const headers = ["domain", "registrar", "status", "category", "purchase_date", "expiry_date", "purchase_price", "renewal_price", "estimated_value", "currency", "auto_renew", "tags", "notes"];
|
|
88
|
+
const rows = domains.map((d) =>
|
|
89
|
+
headers.map((h) => {
|
|
90
|
+
const val = String((d as any)[h] ?? "");
|
|
91
|
+
return val.includes(",") || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val;
|
|
92
|
+
}).join(",")
|
|
93
|
+
);
|
|
94
|
+
const csv = [headers.join(","), ...rows].join("\n");
|
|
95
|
+
writeFileSync(filePath, csv, "utf-8");
|
|
96
|
+
return filePath;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function exportTaxCSV(filePath: string, year: number): string {
|
|
100
|
+
const data = getTaxExportData(year);
|
|
101
|
+
const headers = ["domain", "purchase_date", "purchase_price", "sale_date", "sale_price", "holding_days", "profit", "currency"];
|
|
102
|
+
const rows = data.map((d) => [
|
|
103
|
+
d.domain, d.purchaseDate, d.purchasePrice, d.saleDate || "", d.salePrice ?? "", d.holdingDays ?? "", d.profit, d.currency,
|
|
104
|
+
].map((v) => {
|
|
105
|
+
const s = String(v);
|
|
106
|
+
return s.includes(",") ? `"${s}"` : s;
|
|
107
|
+
}).join(","));
|
|
108
|
+
const csv = [headers.join(","), ...rows].join("\n");
|
|
109
|
+
writeFileSync(filePath, csv, "utf-8");
|
|
110
|
+
return filePath;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function exportTransactionsCSV(filePath: string, domain?: string): string {
|
|
114
|
+
const txns = getTransactions(domain, 1000);
|
|
115
|
+
const headers = ["id", "domain", "type", "amount", "currency", "description", "date"];
|
|
116
|
+
const rows = txns.map((t) =>
|
|
117
|
+
headers.map((h) => {
|
|
118
|
+
const val = String((t as any)[h] ?? "");
|
|
119
|
+
return val.includes(",") || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val;
|
|
120
|
+
}).join(",")
|
|
121
|
+
);
|
|
122
|
+
const csv = [headers.join(","), ...rows].join("\n");
|
|
123
|
+
writeFileSync(filePath, csv, "utf-8");
|
|
124
|
+
return filePath;
|
|
125
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPortfolioDomains,
|
|
3
|
+
createAlert,
|
|
4
|
+
type DbPortfolioDomain,
|
|
5
|
+
type AlertSeverity,
|
|
6
|
+
} from "../db.js";
|
|
7
|
+
import { whoisLookup } from "../whois.js";
|
|
8
|
+
import { checkSsl } from "./ssl-check.js";
|
|
9
|
+
import { httpProbe } from "./http-probe.js";
|
|
10
|
+
import { lookupDns } from "./dns-details.js";
|
|
11
|
+
|
|
12
|
+
export interface HealthCheckResult {
|
|
13
|
+
domain: string;
|
|
14
|
+
whoisOk: boolean;
|
|
15
|
+
dnsOk: boolean;
|
|
16
|
+
httpOk: boolean;
|
|
17
|
+
sslOk: boolean;
|
|
18
|
+
sslDaysLeft: number | null;
|
|
19
|
+
expiryDaysLeft: number | null;
|
|
20
|
+
issues: string[];
|
|
21
|
+
checkedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MonitorReport {
|
|
25
|
+
checked: number;
|
|
26
|
+
healthy: number;
|
|
27
|
+
warnings: number;
|
|
28
|
+
critical: number;
|
|
29
|
+
results: HealthCheckResult[];
|
|
30
|
+
alerts: Array<{ domain: string; severity: string; message: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function daysUntil(dateStr: string | null): number | null {
|
|
34
|
+
if (!dateStr) return null;
|
|
35
|
+
try {
|
|
36
|
+
const d = new Date(dateStr);
|
|
37
|
+
if (isNaN(d.getTime())) return null;
|
|
38
|
+
return Math.floor((d.getTime() - Date.now()) / 86400000);
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function checkDomainHealth(domain: string): Promise<HealthCheckResult> {
|
|
43
|
+
const result: HealthCheckResult = {
|
|
44
|
+
domain,
|
|
45
|
+
whoisOk: false,
|
|
46
|
+
dnsOk: false,
|
|
47
|
+
httpOk: false,
|
|
48
|
+
sslOk: false,
|
|
49
|
+
sslDaysLeft: null,
|
|
50
|
+
expiryDaysLeft: null,
|
|
51
|
+
issues: [],
|
|
52
|
+
checkedAt: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// WHOIS check
|
|
56
|
+
try {
|
|
57
|
+
const whois = await whoisLookup(domain);
|
|
58
|
+
if (!whois.error && !whois.available) {
|
|
59
|
+
result.whoisOk = true;
|
|
60
|
+
result.expiryDaysLeft = daysUntil(whois.expiryDate);
|
|
61
|
+
} else if (whois.available) {
|
|
62
|
+
result.issues.push("Domain appears unregistered!");
|
|
63
|
+
}
|
|
64
|
+
} catch { result.issues.push("WHOIS check failed"); }
|
|
65
|
+
|
|
66
|
+
// DNS check
|
|
67
|
+
try {
|
|
68
|
+
const dns = await lookupDns(domain);
|
|
69
|
+
if (dns.a.length > 0 || dns.cname.length > 0) {
|
|
70
|
+
result.dnsOk = true;
|
|
71
|
+
} else {
|
|
72
|
+
result.issues.push("No DNS A/CNAME records");
|
|
73
|
+
}
|
|
74
|
+
} catch { result.issues.push("DNS check failed"); }
|
|
75
|
+
|
|
76
|
+
// HTTP check
|
|
77
|
+
try {
|
|
78
|
+
const probe = await httpProbe(domain);
|
|
79
|
+
if (probe.reachable && probe.status !== null && probe.status < 500) {
|
|
80
|
+
result.httpOk = true;
|
|
81
|
+
} else {
|
|
82
|
+
result.issues.push(probe.reachable ? `HTTP ${probe.status}` : "Site unreachable");
|
|
83
|
+
}
|
|
84
|
+
} catch { result.issues.push("HTTP check failed"); }
|
|
85
|
+
|
|
86
|
+
// SSL check
|
|
87
|
+
try {
|
|
88
|
+
const ssl = await checkSsl(domain);
|
|
89
|
+
if (ssl.valid && !ssl.error) {
|
|
90
|
+
result.sslOk = true;
|
|
91
|
+
result.sslDaysLeft = ssl.daysUntilExpiry;
|
|
92
|
+
if (ssl.daysUntilExpiry !== null && ssl.daysUntilExpiry < 30) {
|
|
93
|
+
result.issues.push(`SSL expires in ${ssl.daysUntilExpiry} days`);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
result.issues.push(ssl.error || "Invalid SSL certificate");
|
|
97
|
+
}
|
|
98
|
+
} catch { result.issues.push("SSL check failed"); }
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runPortfolioHealthCheck(
|
|
104
|
+
onProgress?: (domain: string, index: number, total: number) => void
|
|
105
|
+
): Promise<MonitorReport> {
|
|
106
|
+
const domains = getPortfolioDomains();
|
|
107
|
+
const report: MonitorReport = {
|
|
108
|
+
checked: 0,
|
|
109
|
+
healthy: 0,
|
|
110
|
+
warnings: 0,
|
|
111
|
+
critical: 0,
|
|
112
|
+
results: [],
|
|
113
|
+
alerts: [],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < domains.length; i++) {
|
|
117
|
+
const d = domains[i]!;
|
|
118
|
+
onProgress?.(d.domain, i, domains.length);
|
|
119
|
+
|
|
120
|
+
const health = await checkDomainHealth(d.domain);
|
|
121
|
+
report.results.push(health);
|
|
122
|
+
report.checked++;
|
|
123
|
+
|
|
124
|
+
// Generate alerts
|
|
125
|
+
// Domain expiry
|
|
126
|
+
if (health.expiryDaysLeft !== null) {
|
|
127
|
+
if (health.expiryDaysLeft <= 7) {
|
|
128
|
+
const msg = `Domain expires in ${health.expiryDaysLeft} days!`;
|
|
129
|
+
createAlert(d.domain, "expiry", "critical", msg);
|
|
130
|
+
report.alerts.push({ domain: d.domain, severity: "critical", message: msg });
|
|
131
|
+
report.critical++;
|
|
132
|
+
} else if (health.expiryDaysLeft <= 30) {
|
|
133
|
+
const msg = `Domain expires in ${health.expiryDaysLeft} days`;
|
|
134
|
+
createAlert(d.domain, "expiry", "warning", msg);
|
|
135
|
+
report.alerts.push({ domain: d.domain, severity: "warning", message: msg });
|
|
136
|
+
report.warnings++;
|
|
137
|
+
} else if (health.expiryDaysLeft <= 90) {
|
|
138
|
+
const msg = `Domain expires in ${health.expiryDaysLeft} days`;
|
|
139
|
+
createAlert(d.domain, "expiry", "info", msg);
|
|
140
|
+
report.alerts.push({ domain: d.domain, severity: "info", message: msg });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// SSL expiry
|
|
145
|
+
if (health.sslDaysLeft !== null && health.sslDaysLeft <= 14) {
|
|
146
|
+
const severity: AlertSeverity = health.sslDaysLeft <= 3 ? "critical" : "warning";
|
|
147
|
+
const msg = `SSL certificate expires in ${health.sslDaysLeft} days`;
|
|
148
|
+
createAlert(d.domain, "ssl-expiry", severity, msg);
|
|
149
|
+
report.alerts.push({ domain: d.domain, severity, message: msg });
|
|
150
|
+
if (severity === "critical") report.critical++; else report.warnings++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Site down
|
|
154
|
+
if (!health.httpOk) {
|
|
155
|
+
const msg = "Site is unreachable or returning errors";
|
|
156
|
+
createAlert(d.domain, "downtime", "warning", msg);
|
|
157
|
+
report.alerts.push({ domain: d.domain, severity: "warning", message: msg });
|
|
158
|
+
report.warnings++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// DNS missing
|
|
162
|
+
if (!health.dnsOk) {
|
|
163
|
+
const msg = "No DNS records found";
|
|
164
|
+
createAlert(d.domain, "dns", "warning", msg);
|
|
165
|
+
report.alerts.push({ domain: d.domain, severity: "warning", message: msg });
|
|
166
|
+
report.warnings++;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (health.issues.length === 0) report.healthy++;
|
|
170
|
+
|
|
171
|
+
// Rate limit
|
|
172
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return report;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function generateRenewalCalendar(monthsAhead: number = 12): Array<{
|
|
179
|
+
domain: string;
|
|
180
|
+
expiryDate: string;
|
|
181
|
+
daysLeft: number;
|
|
182
|
+
renewalPrice: number;
|
|
183
|
+
autoRenew: boolean;
|
|
184
|
+
}> {
|
|
185
|
+
const domains = getPortfolioDomains();
|
|
186
|
+
const calendar: Array<{
|
|
187
|
+
domain: string; expiryDate: string; daysLeft: number; renewalPrice: number; autoRenew: boolean;
|
|
188
|
+
}> = [];
|
|
189
|
+
|
|
190
|
+
const cutoff = Date.now() + monthsAhead * 30 * 86400000;
|
|
191
|
+
|
|
192
|
+
for (const d of domains) {
|
|
193
|
+
if (!d.expiry_date) continue;
|
|
194
|
+
const expiry = new Date(d.expiry_date).getTime();
|
|
195
|
+
if (isNaN(expiry)) continue;
|
|
196
|
+
const daysLeft = Math.floor((expiry - Date.now()) / 86400000);
|
|
197
|
+
if (daysLeft >= 0 && expiry <= cutoff) {
|
|
198
|
+
calendar.push({
|
|
199
|
+
domain: d.domain,
|
|
200
|
+
expiryDate: d.expiry_date,
|
|
201
|
+
daysLeft,
|
|
202
|
+
renewalPrice: d.renewal_price,
|
|
203
|
+
autoRenew: !!d.auto_renew,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return calendar.sort((a, b) => a.daysLeft - b.daysLeft);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function estimateAnnualRenewalCost(): number {
|
|
212
|
+
const domains = getPortfolioDomains();
|
|
213
|
+
return domains.reduce((sum, d) => sum + (d.renewal_price || 0), 0);
|
|
214
|
+
}
|