Init the ddns service, create app, aws and ip modules
All checks were successful
Build and deploy / deploy (push) Successful in 41s

This commit is contained in:
2024-11-11 14:47:39 +00:00
commit 0164290830
22 changed files with 11063 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.log
*.md
.git

25
.eslintrc.js Normal file
View File

@@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

View File

@@ -0,0 +1,36 @@
name: Build and deploy
run-name: Build and deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
github-server-url: 'https://gitea.home.joemonk.co.uk'
-
name: Set up docker
run: 'curl -fsSL https://get.docker.com | sh'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Login to private registry
uses: docker/login-action@v3
with:
registry: 'gitea.home.joemonk.co.uk/${{ github.repository }}'
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: 'gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest'

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"editor.tabSize": 2,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
}

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Use the official Node.js image as the base image
FROM node:22-alpine
# Set the working directory inside the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install the application dependencies
RUN npm install
# Copy the rest of the application files
COPY . .
# Build the NestJS application
RUN npm run build
# Expose the application port
EXPOSE 3000
# Command to run the application
CMD ["node", "dist/main"]

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
## Description
A nestjs service that grabs your ip from https://ifconfig.co and updates a record in route 53 to point to that.
Effectively aws dynamic dns as a docker.
## Project setup
```bash
$ npm install
```
### Env vars
You can use a `.env` file for local dev
```
# The keys from the IAM user below
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
PORT=3000
CRON=0 0 */4 * * *
# The record name is your hostname, i.e. subdomain.domain.co.uk
RECORD_NAME=
# From hosted zone details
HOSTED_ZONE_ID=
```
### AWS
Create a new IAM user and attach a policy to change the records in your zone:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["route53:ChangeResourceRecordSets"],
"Resource": "arn:aws:route53:::hostedzone/HOSTED_ZONE_ID"
}
]
}
```
Create a new access key and use the credentials in the env vars to allow access.
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Layout
### ip module
This module is just to send provide your current ip, whether this is by asking your router, or using a service like
### aws module
This connects to aws and updates the record name to the ip passed in
## app module
Connects everything together

9
nest-cli.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"typeCheck": true
}
}

10584
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "ddns",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-route-53": "^3.687.0",
"@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.1.1",
"joi": "^17.13.3",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

12
src/app/app.controller.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/update')
async update(): Promise<void> {
return this.appService.updateR53();
}
}

24
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { configuration, validationSchema } from '../config/configuration';
import { IpModule } from '../ip/ip.module';
import { AwsModule } from 'src/aws/aws.module';
import { AppService } from './app.service';
import { AppController } from './app.controller';
@Module({
imports: [
ConfigModule.forRoot({
validationSchema,
isGlobal: true,
load: [configuration],
}),
ScheduleModule.forRoot(),
IpModule,
AwsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

37
src/app/app.service.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { AwsService } from 'src/aws/aws.service';
import { IpService } from 'src/ip/ip.service';
@Injectable()
export class AppService {
constructor(
private readonly configService: ConfigService,
private readonly schedulerRegistry: SchedulerRegistry,
private readonly awsService: AwsService,
private readonly ipService: IpService,
) {}
onModuleInit() {
Logger.log(
`Creating R53 update cronjob with: ${this.configService.get('cron')}`,
);
const job = new CronJob(this.configService.get('cron'), () => {
Logger.log('Running R53 update from cron');
this.updateR53();
});
this.schedulerRegistry.addCronJob('UpdateR53', job);
job.start();
}
async updateR53() {
Logger.log('Getting IP');
const ip = await this.ipService.getIp();
Logger.log('Updating R53');
this.awsService.updateR53(ip);
Logger.log('IP updated in R53');
}
}

9
src/aws/aws.module.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AwsService } from './aws.service';
@Module({
imports: [],
providers: [AwsService],
exports: [AwsService],
})
export class AwsModule {}

48
src/aws/aws.service.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ChangeAction,
ChangeResourceRecordSetsCommand,
Route53Client,
RRType,
} from '@aws-sdk/client-route-53';
@Injectable()
export class AwsService {
constructor(private readonly configService: ConfigService) {}
private readonly route53Client = new Route53Client({
region: this.configService.get('aws.region'),
credentials: {
accessKeyId: this.configService.get('aws.accessKeyId'),
secretAccessKey: this.configService.get('aws.secretAccessKey'),
},
});
async updateR53(ip: string): Promise<void> {
const record = this.configService.get('recordName');
const hostedZone = this.configService.get('hostedZoneId');
Logger.log(`Updating ${record} with ${ip}`);
const input = {
ChangeBatch: {
Changes: [
{
Action: ChangeAction.UPSERT,
ResourceRecordSet: {
Name: record,
ResourceRecords: [
{
Value: ip,
},
],
TTL: 300,
Type: RRType.A,
},
},
],
},
HostedZoneId: hostedZone,
};
const command = new ChangeResourceRecordSetsCommand(input);
this.route53Client.send(command);
}
}

View File

@@ -0,0 +1,25 @@
import * as Joi from 'joi';
export function configuration() {
return {
aws: {
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
port: process.env.PORT,
cron: process.env.CRON,
recordName: process.env.RECORD_NAME,
hostedZoneId: process.env.HOSTED_ZONE_ID,
};
}
export const validationSchema = Joi.object({
AWS_REGION: Joi.string().default('eu-west-2'),
AWS_ACCESS_KEY_ID: Joi.string().required(),
AWS_SECRET_ACCESS_KEY: Joi.string().required(),
PORT: Joi.number().port().default(3000),
CRON: Joi.string().required(),
RECORD_NAME: Joi.string().required(),
HOSTED_ZONE_ID: Joi.string().required(),
});

10
src/ip/ip.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IpService } from './ip.service';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [HttpModule],
providers: [IpService],
exports: [IpService],
})
export class IpModule {}

22
src/ip/ip.service.ts Normal file
View File

@@ -0,0 +1,22 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class IpService {
constructor(private readonly httpService: HttpService) {}
async getIp(): Promise<string> {
Logger.log('Getting IP from https://ifconfig.co');
const { data } = await firstValueFrom(
this.httpService.get<string>(`https://ifconfig.co/`, {
headers: {
Accept: 'text/plain',
},
}),
);
const ip = data.trim();
Logger.log(`IP: ${ip}`);
return ip;
}
}

10
src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
await app.listen(configService.get('port'));
}
bootstrap();

4
tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}