Skip to content

Commit dcabe42

Browse files
Merge pull request #122 from sahoo-tech/tunnel
feat: Implement comprehensive tunnel service suite & few errors solved
2 parents be7c3fa + 2b85686 commit dcabe42

22 files changed

Lines changed: 2524 additions & 75 deletions

LocalMind-Backend/package-lock.json

Lines changed: 1307 additions & 65 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LocalMind-Backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@types/argon2": "^0.15.4",
3232
"@types/bcrypt": "^6.0.0",
3333
"@types/express": "^5.0.3",
34+
"@types/localtunnel": "^2.0.4",
3435
"@types/node": "^24.7.2",
3536
"@types/nodemailer": "^7.0.3",
3637
"eslint": "^9.36.0",
@@ -66,6 +67,7 @@
6667
"figlet": "^1.9.3",
6768
"jsonwebtoken": "^9.0.2",
6869
"langchain": "^0.3.36",
70+
"localtunnel": "^2.0.2",
6971
"mongoose": "^8.19.1",
7072
"morgan": "^1.10.1",
7173
"ngrok": "5.0.0-beta.2",

LocalMind-Backend/src/api/v1/AiModelConfig/AiModelConfig.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class AiModelConfig_Controller {
1515
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token
1616
if (!token) throw new Error('Authentication token missing')
1717

18-
const FindUserByToken = await UserUtils.VerifyUserToken(token)
18+
const FindUserByToken = await UserUtils.verifyToken(token)
1919

2020
if (!FindUserByToken) throw new Error('Invalid authentication token')
2121

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import localtunnel from 'localtunnel'
2+
import TunnelConstant from './Tunnel.constant'
3+
import TunnelUtils from './Tunnel.utils'
4+
import { ITunnelStartResponse, ITunnelStatus } from './Tunnel.type'
5+
6+
interface LocalTunnelInstance {
7+
url: string
8+
close: () => void
9+
}
10+
11+
class LocalTunnelService {
12+
private activeTunnel: LocalTunnelInstance | null = null
13+
private tunnelUrl: string | null = null
14+
private tunnelPort: number | null = null
15+
private startedAt: Date | null = null
16+
17+
/**
18+
* Start a localtunnel
19+
*/
20+
async startTunnel(port: number, subdomain?: string): Promise<ITunnelStartResponse> {
21+
// Check if tunnel is already running
22+
if (this.activeTunnel) {
23+
throw new Error(TunnelConstant.TUNNEL_ALREADY_RUNNING)
24+
}
25+
26+
// Validate port
27+
const validation = TunnelUtils.validateTunnelConfig(port, subdomain)
28+
if (!validation.valid) {
29+
throw new Error(validation.error || TunnelConstant.INVALID_PORT)
30+
}
31+
32+
try {
33+
// Start the tunnel
34+
const tunnel = await localtunnel({
35+
port,
36+
subdomain,
37+
})
38+
39+
// Store tunnel instance
40+
this.activeTunnel = tunnel
41+
this.tunnelUrl = tunnel.url || ''
42+
this.tunnelPort = port
43+
this.startedAt = new Date()
44+
45+
return {
46+
url: tunnel.url,
47+
port: port,
48+
status: 'active',
49+
}
50+
} catch (error: any) {
51+
this.activeTunnel = null
52+
this.tunnelUrl = null
53+
this.tunnelPort = null
54+
this.startedAt = null
55+
56+
throw new Error(`${TunnelConstant.TUNNEL_START_FAILED}: ${error.message}`)
57+
}
58+
}
59+
60+
/**
61+
* Stop the active tunnel
62+
*/
63+
async stopTunnel(): Promise<{ message: string; previousUrl?: string }> {
64+
if (!this.activeTunnel) {
65+
throw new Error(TunnelConstant.TUNNEL_NOT_RUNNING)
66+
}
67+
68+
const previousUrl = this.tunnelUrl
69+
70+
try {
71+
this.activeTunnel.close()
72+
73+
this.activeTunnel = null
74+
this.tunnelUrl = null
75+
this.tunnelPort = null
76+
this.startedAt = null
77+
78+
return {
79+
message: TunnelConstant.TUNNEL_STOPPED,
80+
...(previousUrl && { previousUrl }),
81+
}
82+
} catch (error: any) {
83+
throw new Error(`${TunnelConstant.TUNNEL_STOP_FAILED}: ${error.message}`)
84+
}
85+
}
86+
87+
/**
88+
* Get tunnel status
89+
*/
90+
getTunnelStatus(): ITunnelStatus {
91+
if (!this.activeTunnel) {
92+
return {
93+
active: false,
94+
}
95+
}
96+
97+
const status: ITunnelStatus = {
98+
active: true,
99+
}
100+
101+
if (this.tunnelUrl) status.url = this.tunnelUrl
102+
if (this.tunnelPort) status.port = this.tunnelPort
103+
if (this.startedAt) status.startedAt = this.startedAt
104+
105+
return status
106+
}
107+
108+
/**
109+
* Check if tunnel is active
110+
*/
111+
isTunnelActive(): boolean {
112+
return this.activeTunnel !== null
113+
}
114+
}
115+
116+
export default new LocalTunnelService()
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import ngrok from 'ngrok'
2+
import TunnelConstant from './Tunnel.constant'
3+
import TunnelUtils from './Tunnel.utils'
4+
import { ITunnelStartResponse, ITunnelStatus } from './Tunnel.type'
5+
6+
interface NgrokConfig {
7+
authtoken?: string
8+
port: number
9+
domain?: string
10+
}
11+
12+
class NgrokService {
13+
private tunnelUrl: string | null = null
14+
private tunnelPort: number | null = null
15+
private startedAt: Date | null = null
16+
private isActive: boolean = false
17+
18+
/**
19+
* Start an ngrok tunnel
20+
*/
21+
async startTunnel(port: number, authToken?: string, domain?: string): Promise<ITunnelStartResponse> {
22+
// Check if tunnel is already running
23+
if (this.isActive) {
24+
throw new Error(TunnelConstant.TUNNEL_ALREADY_RUNNING)
25+
}
26+
27+
// Validate port
28+
const validation = TunnelUtils.validateTunnelConfig(port)
29+
if (!validation.valid) {
30+
throw new Error(validation.error || TunnelConstant.INVALID_PORT)
31+
}
32+
33+
try {
34+
// Configure ngrok options
35+
const options: any = {
36+
addr: port,
37+
}
38+
39+
if (authToken) {
40+
options.authtoken = authToken
41+
}
42+
43+
if (domain) {
44+
options.hostname = domain
45+
}
46+
47+
// Start the tunnel
48+
const url = await ngrok.connect(options)
49+
50+
// Store tunnel info
51+
this.tunnelUrl = url
52+
this.tunnelPort = port
53+
this.startedAt = new Date()
54+
this.isActive = true
55+
56+
return {
57+
url: this.tunnelUrl,
58+
port: port,
59+
status: 'active',
60+
}
61+
} catch (error: any) {
62+
this.tunnelUrl = null
63+
this.tunnelPort = null
64+
this.startedAt = null
65+
this.isActive = false
66+
67+
// Check for common ngrok errors
68+
if (error.message?.includes('authtoken')) {
69+
throw new Error('Invalid or missing ngrok authtoken')
70+
}
71+
72+
throw new Error(`${TunnelConstant.TUNNEL_START_FAILED}: ${error.message}`)
73+
}
74+
}
75+
76+
/**
77+
* Stop the active tunnel
78+
*/
79+
async stopTunnel(): Promise<{ message: string; previousUrl?: string }> {
80+
if (!this.isActive) {
81+
throw new Error(TunnelConstant.TUNNEL_NOT_RUNNING)
82+
}
83+
84+
const previousUrl = this.tunnelUrl
85+
86+
try {
87+
await ngrok.disconnect()
88+
await ngrok.kill()
89+
90+
this.tunnelUrl = null
91+
this.tunnelPort = null
92+
this.startedAt = null
93+
this.isActive = false
94+
95+
return {
96+
message: TunnelConstant.TUNNEL_STOPPED,
97+
...(previousUrl && { previousUrl }),
98+
}
99+
} catch (error: any) {
100+
throw new Error(`${TunnelConstant.TUNNEL_STOP_FAILED}: ${error.message}`)
101+
}
102+
}
103+
104+
/**
105+
* Get tunnel status
106+
*/
107+
getTunnelStatus(): ITunnelStatus {
108+
if (!this.isActive) {
109+
return {
110+
active: false,
111+
}
112+
}
113+
114+
const status: ITunnelStatus = {
115+
active: true,
116+
}
117+
118+
if (this.tunnelUrl) status.url = this.tunnelUrl
119+
if (this.tunnelPort) status.port = this.tunnelPort
120+
if (this.startedAt) status.startedAt = this.startedAt
121+
122+
return status
123+
}
124+
125+
/**
126+
* Check if tunnel is active
127+
*/
128+
isTunnelActive(): boolean {
129+
return this.isActive
130+
}
131+
}
132+
133+
export default new NgrokService()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default class TunnelConstant {
2+
// Success Messages
3+
static readonly TUNNEL_STARTED = 'Cloudflared tunnel started successfully'
4+
static readonly TUNNEL_STOPPED = 'Cloudflared tunnel stopped successfully'
5+
static readonly TUNNEL_STATUS_RETRIEVED = 'Tunnel status retrieved successfully'
6+
7+
// Error Messages
8+
static readonly TUNNEL_ALREADY_RUNNING = 'A cloudflared tunnel is already running'
9+
static readonly TUNNEL_NOT_RUNNING = 'No cloudflared tunnel is currently running'
10+
static readonly TUNNEL_START_FAILED = 'Failed to start cloudflared tunnel'
11+
static readonly TUNNEL_STOP_FAILED = 'Failed to stop cloudflared tunnel'
12+
static readonly INVALID_PORT = 'Invalid port number provided'
13+
static readonly CLOUDFLARED_NOT_INSTALLED = 'Cloudflared binary not found on system'
14+
static readonly TUNNEL_ERROR = 'An error occurred with the tunnel service'
15+
16+
// Configuration
17+
static readonly DEFAULT_PORT = 3000
18+
static readonly MIN_PORT = 1024
19+
static readonly MAX_PORT = 65535
20+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Request, Response } from 'express'
2+
import { tunnelStartSchema } from './Tunnel.validator'
3+
import tunnelService from './Tunnel.service'
4+
import { SendResponse } from '../../../utils/SendResponse.utils'
5+
import TunnelConstant from './Tunnel.constant'
6+
import { StatusConstant } from '../../../constant/Status.constant'
7+
8+
class TunnelController {
9+
constructor() {
10+
this.startTunnel = this.startTunnel.bind(this)
11+
this.stopTunnel = this.stopTunnel.bind(this)
12+
this.getTunnelStatus = this.getTunnelStatus.bind(this)
13+
}
14+
15+
/**
16+
* Start a cloudflared tunnel
17+
* POST /api/v1/expose/cloudflared
18+
*/
19+
async startTunnel(req: Request, res: Response): Promise<void> {
20+
try {
21+
const validatedData = await tunnelStartSchema.parseAsync(req.body)
22+
23+
const result = await tunnelService.startTunnel(validatedData.port, validatedData.subdomain)
24+
25+
SendResponse.success(res, TunnelConstant.TUNNEL_STARTED, result, StatusConstant.CREATED)
26+
} catch (err: any) {
27+
if (err?.name === 'ZodError') {
28+
SendResponse.error(
29+
res,
30+
err.errors?.[0]?.message || TunnelConstant.INVALID_PORT,
31+
StatusConstant.BAD_REQUEST,
32+
err
33+
)
34+
return
35+
}
36+
37+
if (err.message === TunnelConstant.TUNNEL_ALREADY_RUNNING) {
38+
SendResponse.error(res, err.message, StatusConstant.CONFLICT, err)
39+
return
40+
}
41+
42+
if (err.message === TunnelConstant.CLOUDFLARED_NOT_INSTALLED) {
43+
SendResponse.error(res, err.message, StatusConstant.SERVICE_UNAVAILABLE, err)
44+
return
45+
}
46+
47+
SendResponse.error(
48+
res,
49+
err.message || TunnelConstant.TUNNEL_START_FAILED,
50+
StatusConstant.INTERNAL_SERVER_ERROR,
51+
err
52+
)
53+
}
54+
}
55+
56+
/**
57+
* Stop the active tunnel
58+
* DELETE /api/v1/expose/cloudflared/stop
59+
*/
60+
async stopTunnel(req: Request, res: Response): Promise<void> {
61+
try {
62+
const result = await tunnelService.stopTunnel()
63+
64+
SendResponse.success(res, result.message, { previousUrl: result.previousUrl }, StatusConstant.OK)
65+
} catch (err: any) {
66+
if (err.message === TunnelConstant.TUNNEL_NOT_RUNNING) {
67+
SendResponse.error(res, err.message, StatusConstant.NOT_FOUND, err)
68+
return
69+
}
70+
71+
SendResponse.error(
72+
res,
73+
err.message || TunnelConstant.TUNNEL_STOP_FAILED,
74+
StatusConstant.INTERNAL_SERVER_ERROR,
75+
err
76+
)
77+
}
78+
}
79+
80+
/**
81+
* Get tunnel status
82+
* GET /api/v1/expose/cloudflared/status
83+
*/
84+
async getTunnelStatus(req: Request, res: Response): Promise<void> {
85+
try {
86+
const status = tunnelService.getTunnelStatus()
87+
88+
SendResponse.success(res, TunnelConstant.TUNNEL_STATUS_RETRIEVED, status, StatusConstant.OK)
89+
} catch (err: any) {
90+
SendResponse.error(
91+
res,
92+
err.message || TunnelConstant.TUNNEL_ERROR,
93+
StatusConstant.INTERNAL_SERVER_ERROR,
94+
err
95+
)
96+
}
97+
}
98+
}
99+
100+
export default new TunnelController()

0 commit comments

Comments
 (0)