Skip to content

Commit a5933ff

Browse files
authored
feat(proxy): update sandbox last activity (#2754)
Signed-off-by: Toma Puljak <[email protected]>
1 parent 41cf552 commit a5933ff

File tree

16 files changed

+933
-49
lines changed

16 files changed

+933
-49
lines changed

apps/api/src/organization/guards/organization-access.guard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export class OrganizationAccessGuard implements CanActivate {
3939
if (
4040
authContext.role !== 'ssh-gateway' &&
4141
authContext.role !== 'runner' &&
42+
authContext.role !== 'proxy' &&
4243
!organizationIdParam &&
4344
!authContext.organizationId
4445
) {

apps/api/src/sandbox/controllers/sandbox.controller.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import { AuditTarget } from '../../audit/enums/audit-target.enum'
6969
import { SshAccessDto, SshAccessValidationDto } from '../dto/ssh-access.dto'
7070
import { ListSandboxesQueryDto } from '../dto/list-sandboxes-query.dto'
7171
import { RegionDto } from '../dto/region.dto'
72+
import { ProxyGuard } from '../../auth/proxy.guard'
73+
import { OrGuard } from '../../auth/or.guard'
7274

7375
@ApiTags('sandbox')
7476
@Controller('sandbox')
@@ -607,6 +609,25 @@ export class SandboxController {
607609
return SandboxDto.fromSandbox(sandbox)
608610
}
609611

612+
@Post(':sandboxId/last-activity')
613+
@ApiOperation({
614+
summary: 'Update sandbox last activity',
615+
operationId: 'updateLastActivity',
616+
})
617+
@ApiParam({
618+
name: 'sandboxId',
619+
description: 'ID of the sandbox',
620+
type: 'string',
621+
})
622+
@ApiResponse({
623+
status: 201,
624+
description: 'Last activity has been updated',
625+
})
626+
@UseGuards(OrGuard([SandboxAccessGuard, ProxyGuard]))
627+
async updateLastActivity(@Param('sandboxId') sandboxId: string): Promise<void> {
628+
await this.sandboxService.updateLastActivityAt(sandboxId, new Date())
629+
}
630+
610631
@Post(':sandboxIdOrName/autostop/:interval')
611632
@ApiOperation({
612633
summary: 'Set sandbox auto-stop interval',

apps/api/src/sandbox/entities/sandbox.entity.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { SandboxState } from '../enums/sandbox-state.enum'
1818
import { SandboxDesiredState } from '../enums/sandbox-desired-state.enum'
1919
import { SandboxClass } from '../enums/sandbox-class.enum'
2020
import { BackupState } from '../enums/backup-state.enum'
21-
import { nanoid } from 'nanoid'
2221
import { v4 as uuidv4 } from 'uuid'
2322
import { SandboxVolume } from '../dto/sandbox.dto'
2423
import { BuildInfo } from './build-info.entity'
@@ -245,13 +244,6 @@ export class Sandbox {
245244
}
246245
}
247246

248-
@BeforeUpdate()
249-
updateAccessToken() {
250-
if (this.state === SandboxState.STARTED) {
251-
this.authToken = nanoid(32).toLocaleLowerCase()
252-
}
253-
}
254-
255247
@BeforeUpdate()
256248
updateLastActivityAt() {
257249
this.lastActivityAt = new Date()

apps/api/src/sandbox/guards/sandbox-access.guard.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { SandboxService } from '../services/sandbox.service'
88
import { OrganizationAuthContext, BaseAuthContext } from '../../common/interfaces/auth-context.interface'
99
import { isRunnerContext, RunnerContext } from '../../common/interfaces/runner-context.interface'
1010
import { SystemRole } from '../../user/enums/system-role.enum'
11+
import { isProxyContext } from '../../common/interfaces/proxy-context.interface'
12+
import { isSshGatewayContext } from '../../common/interfaces/ssh-gateway-context.interface'
1113

1214
@Injectable()
1315
export class SandboxAccessGuard implements CanActivate {
@@ -23,30 +25,31 @@ export class SandboxAccessGuard implements CanActivate {
2325
const authContext: BaseAuthContext = request.user
2426

2527
try {
26-
// Check if this is a runner making the request
27-
if (isRunnerContext(authContext)) {
28-
// For runner authentication, verify that the runner ID matches the sandbox's runner ID
29-
const runnerContext = authContext as RunnerContext
30-
const sandboxRunnerId = await this.sandboxService.getRunnerId(sandboxIdOrName)
31-
if (sandboxRunnerId !== runnerContext.runnerId) {
32-
throw new ForbiddenException('Runner ID does not match sandbox runner ID')
28+
switch (true) {
29+
case isRunnerContext(authContext): {
30+
// For runner authentication, verify that the runner ID matches the sandbox's runner ID
31+
const runnerContext = authContext as RunnerContext
32+
const sandboxRunnerId = await this.sandboxService.getRunnerId(sandboxIdOrName)
33+
if (sandboxRunnerId !== runnerContext.runnerId) {
34+
throw new ForbiddenException('Runner ID does not match sandbox runner ID')
35+
}
36+
break
3337
}
34-
} else {
35-
// For user/organization authentication, check organization access
36-
const orgAuthContext = authContext as OrganizationAuthContext
37-
const sandboxOrganizationId = await this.sandboxService.getOrganizationId(
38-
sandboxIdOrName,
39-
orgAuthContext.organizationId,
40-
)
41-
if (
42-
orgAuthContext.role !== 'ssh-gateway' &&
43-
orgAuthContext.role !== SystemRole.ADMIN &&
44-
sandboxOrganizationId !== orgAuthContext.organizationId
45-
) {
46-
throw new ForbiddenException('Request organization ID does not match resource organization ID')
38+
case isProxyContext(authContext):
39+
case isSshGatewayContext(authContext):
40+
return true
41+
default: {
42+
// For user/organization authentication, check organization access
43+
const orgAuthContext = authContext as OrganizationAuthContext
44+
const sandboxOrganizationId = await this.sandboxService.getOrganizationId(
45+
sandboxIdOrName,
46+
orgAuthContext.organizationId,
47+
)
48+
if (orgAuthContext.role !== SystemRole.ADMIN && sandboxOrganizationId !== orgAuthContext.organizationId) {
49+
throw new ForbiddenException('Request organization ID does not match resource organization ID')
50+
}
4751
}
4852
}
49-
5053
return true
5154
} catch (error) {
5255
if (!(error instanceof NotFoundException)) {

apps/api/src/sandbox/services/sandbox.service.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ import {
6262
ARCHIVE_SANDBOXES_MESSAGE,
6363
PER_SANDBOX_LIMIT_MESSAGE,
6464
} from '../../common/constants/error-messages'
65-
import { customAlphabet as customNanoid, urlAlphabet } from 'nanoid'
65+
import { RedisLockProvider } from '../common/redis-lock.provider'
66+
import { customAlphabet as customNanoid, nanoid, urlAlphabet } from 'nanoid'
6667
import { WithInstrumentation } from '../../common/decorators/otel.decorator'
6768
import { validateMountPaths } from '../utils/volume-mount-path-validation.util'
6869
import { SandboxRepository } from '../repositories/sandbox.repository'
@@ -94,6 +95,7 @@ export class SandboxService {
9495
private readonly organizationService: OrganizationService,
9596
private readonly runnerAdapterFactory: RunnerAdapterFactory,
9697
private readonly organizationUsageService: OrganizationUsageService,
98+
private readonly redisLockProvider: RedisLockProvider,
9799
) {}
98100

99101
protected getLockKey(id: string): string {
@@ -1004,6 +1006,7 @@ export class SandboxService {
10041006

10051007
sandbox.pending = true
10061008
sandbox.desiredState = SandboxDesiredState.STARTED
1009+
sandbox.authToken = nanoid(32).toLocaleLowerCase()
10071010

10081011
try {
10091012
await this.sandboxRepository.saveWhere(sandbox, { pending: false, state: sandbox.state })
@@ -1064,6 +1067,21 @@ export class SandboxService {
10641067
return sandbox
10651068
}
10661069

1070+
async updateLastActivityAt(sandboxId: string, lastActivityAt: Date): Promise<void> {
1071+
// Prevent spamming updates
1072+
const lockKey = `sandbox:update-last-activity:${sandboxId}`
1073+
const acquired = await this.redisLockProvider.lock(lockKey, 45)
1074+
if (!acquired) {
1075+
return
1076+
}
1077+
1078+
const result = await this.sandboxRepository.update({ id: sandboxId }, { lastActivityAt })
1079+
1080+
if (!result.affected) {
1081+
throw new NotFoundException(`Sandbox with ID ${sandboxId} not found`)
1082+
}
1083+
}
1084+
10671085
private getValidatedOrDefaultRegion(region?: string): string {
10681086
if (!region || region.trim().length === 0) {
10691087
return 'us'

apps/docs/src/content/docs/en/custom-domain-authentication.mdx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,17 @@ To override Daytona's default CORS settings, send:
4848
X-Daytona-Disable-CORS: true
4949
```
5050

51-
#### Authentication
51+
### Disable Last Activity Update
52+
53+
To prevent sandbox last activity updates when previewing, you can set the `X-Daytona-Skip-Last-Activity-Update` header to `true`.
54+
This will stop Daytona from keeping sandboxes, that have [auto-stop enabled](/docs/sandbox-management#auto-stop-interval), started:
55+
56+
```bash
57+
curl -H "X-Daytona-Skip-Last-Activity-Update: true" \
58+
https://3000-sandbox-123456.proxy.daytona.work
59+
```
60+
61+
### Authentication
5262

5363
For private preview links, users should send:
5464

apps/docs/src/content/docs/en/sandbox-management.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,6 @@ Setting ["autoDeleteInterval: 0"](#auto-delete-interval) has the same effect as
208208
Daytona Sandboxes provide configurable network firewall controls to enhance security and manage connectivity. By default, network access follows standard security policies, but you can customize network settings when creating a Sandbox.
209209
Learn more about network limits in the [Network Limits](/docs/en/network-limits) documentation.
210210

211-
212211
## Sandbox Information
213212

214213
The Daytona SDK provides methods to get information about a Sandbox, such as ID, root directory, and status using Python and TypeScript.
@@ -389,15 +388,15 @@ Daytona Sandboxes can be automatically stopped, archived, and deleted based on u
389388

390389
The auto-stop interval parameter sets the amount of time after which a running Sandbox will be automatically stopped.
391390

391+
Sandbox activity, such as SDK API calls or network requests through [preview URLs](/docs/en/preview-and-authentication), will reset the auto-stop timer.
392+
392393
The parameter can either be set to:
393394

394395
- a time interval in minutes
395396
- `0`, which disables the auto-stop functionality, allowing the sandbox to run indefinitely
396397

397398
If the parameter is not set, the default interval of `15` minutes will be used.
398399

399-
:::
400-
401400
<Tabs>
402401
<TabItem label="Python" icon="seti:python">
403402
```python

apps/proxy/pkg/proxy/auth.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ func (p *Proxy) Authenticate(ctx *gin.Context, sandboxId string) (err error, did
2929
}
3030
}
3131

32-
authKey := ctx.Request.Header.Get(DAYTONA_SANDBOX_AUTH_KEY_HEADER)
32+
authKey := ctx.Request.Header.Get(SANDBOX_AUTH_KEY_HEADER)
3333
if authKey == "" {
34-
if ctx.Query(DAYTONA_SANDBOX_AUTH_KEY_QUERY_PARAM) != "" {
35-
authKey = ctx.Query(DAYTONA_SANDBOX_AUTH_KEY_QUERY_PARAM)
34+
if ctx.Query(SANDBOX_AUTH_KEY_QUERY_PARAM) != "" {
35+
authKey = ctx.Query(SANDBOX_AUTH_KEY_QUERY_PARAM)
3636
newQuery := ctx.Request.URL.Query()
37-
newQuery.Del(DAYTONA_SANDBOX_AUTH_KEY_QUERY_PARAM)
37+
newQuery.Del(SANDBOX_AUTH_KEY_QUERY_PARAM)
3838
ctx.Request.URL.RawQuery = newQuery.Encode()
3939
} else {
4040
// Check for cookie
41-
cookieSandboxId, err := ctx.Cookie(DAYTONA_SANDBOX_AUTH_COOKIE_NAME + sandboxId)
41+
cookieSandboxId, err := ctx.Cookie(SANDBOX_AUTH_COOKIE_NAME + sandboxId)
4242
if err == nil && cookieSandboxId != "" {
4343
decodedValue := ""
44-
err = p.secureCookie.Decode(DAYTONA_SANDBOX_AUTH_COOKIE_NAME+sandboxId, cookieSandboxId, &decodedValue)
44+
err = p.secureCookie.Decode(SANDBOX_AUTH_COOKIE_NAME+sandboxId, cookieSandboxId, &decodedValue)
4545
if err != nil {
4646
return errors.New("sandbox not found"), false
4747
}

apps/proxy/pkg/proxy/auth_callback.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,13 @@ func (p *Proxy) AuthCallback(ctx *gin.Context) {
9696
return
9797
}
9898

99-
encoded, err := p.secureCookie.Encode(DAYTONA_SANDBOX_AUTH_COOKIE_NAME+sandboxId, sandboxId)
99+
encoded, err := p.secureCookie.Encode(SANDBOX_AUTH_COOKIE_NAME+sandboxId, sandboxId)
100100
if err != nil {
101101
ctx.Error(common_errors.NewBadRequestError(fmt.Errorf("failed to encode cookie: %w", err)))
102102
return
103103
}
104104

105-
ctx.SetCookie(DAYTONA_SANDBOX_AUTH_COOKIE_NAME+sandboxId, encoded, 3600, "/", p.cookieDomain, p.config.EnableTLS, true)
105+
ctx.SetCookie(SANDBOX_AUTH_COOKIE_NAME+sandboxId, encoded, 3600, "/", p.cookieDomain, p.config.EnableTLS, true)
106106

107107
// Redirect back to the original URL
108108
ctx.Redirect(http.StatusFound, returnTo)

apps/proxy/pkg/proxy/get_target.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*url.URL, map[string]string, e
5959
return nil, nil, fmt.Errorf("failed to get runner info: %w", err)
6060
}
6161

62+
// Skip last activity update if header is set
63+
if ctx.Request.Header.Get(SKIP_LAST_ACTIVITY_UPDATE_HEADER) != "true" {
64+
p.updateLastActivity(ctx.Request.Context(), sandboxID, true)
65+
ctx.Request.Header.Del(SKIP_LAST_ACTIVITY_UPDATE_HEADER)
66+
}
67+
6268
// Build the target URL
6369
targetURL := fmt.Sprintf("%s/sandboxes/%s/toolbox/proxy/%s", runnerInfo.ApiUrl, sandboxID, targetPort)
6470

@@ -204,3 +210,43 @@ func (p *Proxy) parseHost(host string) (targetPort string, sandboxID string, err
204210

205211
return targetPort, sandboxID, nil
206212
}
213+
214+
func (p *Proxy) updateLastActivity(ctx context.Context, sandboxId string, shouldPollUpdate bool) {
215+
// Prevent frequent updates by caching the last update
216+
cached, err := p.sandboxLastActivityUpdateCache.Has(ctx, sandboxId)
217+
if err != nil {
218+
// If cache doesn't work, skip the update to avoid spamming the API
219+
log.Errorf("failed to check last activity update cache for sandbox %s: %v", sandboxId, err)
220+
return
221+
}
222+
223+
if !cached {
224+
_, err := p.apiclient.SandboxAPI.UpdateLastActivity(ctx, sandboxId).Execute()
225+
if err != nil {
226+
log.Errorf("failed to update last activity for sandbox %s: %v", sandboxId, err)
227+
return
228+
}
229+
230+
err = p.sandboxLastActivityUpdateCache.Set(ctx, sandboxId, true, 45*time.Second)
231+
if err != nil {
232+
log.Errorf("failed to set last activity update cache for sandbox %s: %v", sandboxId, err)
233+
}
234+
}
235+
236+
if shouldPollUpdate {
237+
// Update keep alive every 45 seconds until the request is done
238+
go func() {
239+
ticker := time.NewTicker(45 * time.Second)
240+
defer ticker.Stop()
241+
242+
for {
243+
select {
244+
case <-ticker.C:
245+
p.updateLastActivity(ctx, sandboxId, false)
246+
case <-ctx.Done():
247+
return
248+
}
249+
}
250+
}()
251+
}
252+
}

0 commit comments

Comments
 (0)