diff --git a/.gitignore b/.gitignore
index fd95879..da7cd76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -105,4 +105,5 @@ tests/verdict/
tests/reports/
tests/logs/
coverage-reports/
-coverage
\ No newline at end of file
+coverage
+API_DOCUMENTATION.md
\ No newline at end of file
diff --git a/README.md b/README.md
index d8a45f1..699c5f9 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
+



@@ -16,7 +17,7 @@ Lightweight dynamic DNS service that lets you manage your DNS records without ma
---
-
+
## 📖 Overview
Dyno is a self-hosted dynamic DNS service that eliminates the manual overhead of managing DNS records. Bring your own domain and Cloudflare tokens, and let Dyno handle the rest. Perfect for home labs, development environments, and self-hosted infrastructure.
@@ -63,7 +64,7 @@ Dyno is a self-hosted dynamic DNS service that eliminates the manual overhead of
1. **Pull the Docker image**:
```bash
- docker pull ghcr.io/rndmcodeguy20/dyno:latest
+ docker pull ghcr.io/rndmcodeguy20/dyno:production
```
2. **Create a `.env` file**:
diff --git a/assets/dyno.png b/assets/dyno.png
new file mode 100644
index 0000000..1fb8c3e
Binary files /dev/null and b/assets/dyno.png differ
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 8e568f3..9e24dfa 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -44,7 +44,7 @@ func main() {
}
}(baseLogger)
- baseLogger.Sugar().Infof("Starting %s srv on https://%s:%d in %s mode", "Dyno", cfg.Server.Host, cfg.Server.Port, cfg.Environment)
+ baseLogger.Sugar().Infof("Starting %s server on https://%s:%d in %s mode", "Dyno", cfg.Server.Host, cfg.Server.Port, cfg.Environment)
db, err := database.NewPostgresDB(cfg.DB)
if err != nil {
diff --git a/db/migrations/001_seed.sql b/db/migrations/001_seed.sql
index 5367fec..d272f05 100644
--- a/db/migrations/001_seed.sql
+++ b/db/migrations/001_seed.sql
@@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS domain_records (
id SERIAL PRIMARY KEY,
- domain_name VARCHAR(255) NOT NULL,
+ domain_name VARCHAR(255) NOT NULL UNIQUE,
current_ip_v4 INET NOT NULL,
current_ip_v6 INET,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -58,8 +58,7 @@ CREATE TABLE IF NOT EXISTS domain_records (
dns_record_id_v4 VARCHAR(32) UNIQUE NULL,
dns_record_id_v6 VARCHAR(32) UNIQUE NULL,
provider provider NOT NULL DEFAULT 'cloudflare',
- user_id INT REFERENCES users(id) ON DELETE CASCADE,
- UNIQUE (domain_name, user_id)
+ user_id INT REFERENCES users(id) ON DELETE CASCADE
);
-- Add trigger to auto-update updated_at timestamp
diff --git a/db/migrations/005_update_unique_constraint_on_domain_records.sql b/db/migrations/005_update_unique_constraint_on_domain_records.sql
new file mode 100644
index 0000000..bef65c5
--- /dev/null
+++ b/db/migrations/005_update_unique_constraint_on_domain_records.sql
@@ -0,0 +1,5 @@
+--+ migrate up
+-- ALTER TABLE domain_records
+-- DROP CONSTRAINT IF EXISTS domain_records_domain_name_user_id_key;
+-- ALTER TABLE domain_records
+-- ADD CONSTRAINT unique_domains UNIQUE (domain_name);
\ No newline at end of file
diff --git a/dockerfile b/dockerfile
index ad97bb6..39015b8 100644
--- a/dockerfile
+++ b/dockerfile
@@ -18,6 +18,7 @@ ARG VERSION
ARG COMMIT_HASH
ARG ENV
ARG BUILD_TIME
+ARG HOST
# Build static binary
RUN CGO_ENABLED=0 \
diff --git a/internal/models/api.go b/internal/models/api.go
index 4ae6d0b..7e196e0 100644
--- a/internal/models/api.go
+++ b/internal/models/api.go
@@ -2,18 +2,17 @@ package models
// CreateDomainRequest represents the payload to create a new domain
type CreateDomainRequest struct {
- UserID string `json:"user_id" binding:"required"`
- DomainName string `json:"domain_name" binding:"required"`
- IPV4 string `json:"ip_v4,omitempty" binding:"required"`
- IPV6 string `json:"ip_v6,omitempty"`
+ UserID string `json:"userID" binding:"required"`
+ DomainName string `json:"domainName" binding:"required"`
+ IPV4 string `json:"IPV4,omitempty" binding:"required"`
+ IPV6 string `json:"IPV6,omitempty"`
}
// UpdateDomainRequest represents the payload to update an existing domain
type UpdateDomainRequest struct {
- UserID string `json:"user_id" binding:"required"`
- DomainName string `json:"domain_name"`
- NewIPV4 string `json:"new_ip_v4,omitempty" binding:"required"`
- NewIPV6 string `json:"new_ip_v6,omitempty"`
+ UserID string `json:"userID" binding:"required"`
+ NewIPV4 string `json:"newIPV4,omitempty" binding:"required"`
+ NewIPV6 string `json:"newIPV6,omitempty"`
}
// DeleteDomainRequest represents the payload to delete a domain
diff --git a/internal/service/domain.go b/internal/service/domain.go
index 334e2f2..de1864b 100644
--- a/internal/service/domain.go
+++ b/internal/service/domain.go
@@ -130,8 +130,9 @@ func (s *domainService) CreateDomainRecord(ctx context.Context, body models.Crea
},
})
if err != nil {
+
s.logger.Error("Failed to create A record in Cloudflare", zap.Error(err))
- return "", err
+ return "", errors.NewBadRequestError("Failed to create A record in Cloudflare", err)
}
dnsRecordIds = append(dnsRecordIds, response.ID)
}
@@ -285,7 +286,7 @@ func (s *domainService) DeleteDomainRecord(ctx context.Context, domainId string,
zap.String("dns_record_id_v6", currentDomainRecord.DNSRecordIdV6.String),
)
- if currentDomainRecord.CurrentIPV4.Valid {
+ if currentDomainRecord.DNSRecordIdV4.Valid {
_, err := s.client.DNS.Records.Delete(
ctx,
currentDomainRecord.DNSRecordIdV4.String,
@@ -299,7 +300,7 @@ func (s *domainService) DeleteDomainRecord(ctx context.Context, domainId string,
}
}
- if currentDomainRecord.CurrentIPV6.Valid {
+ if currentDomainRecord.DNSRecordIdV6.Valid {
_, err := s.client.DNS.Records.Delete(
ctx,
currentDomainRecord.DNSRecordIdV6.String,
diff --git a/openapi.yaml b/openapi.yaml
new file mode 100644
index 0000000..2114989
--- /dev/null
+++ b/openapi.yaml
@@ -0,0 +1,675 @@
+openapi: 3.0.3
+info:
+ title: Dyno API
+ description: |
+ Dynamic DNS API for managing domain records and IP address updates.
+ Dyno allows you to manage your DNS records programmatically and keep them updated with your current IP addresses.
+ version: 1.0.0
+ contact:
+ name: Dyno API Support
+ license:
+ name: MIT
+
+servers:
+ - url: http://localhost:5010/api/v1
+ description: Local development server
+ - url: https://api.dyno.rndmcode.in/api/v1
+ description: Production server
+
+tags:
+ - name: Health
+ description: API health and status endpoints
+ - name: Authentication
+ description: User authentication and authorization
+ - name: User
+ description: User profile management
+ - name: Domains
+ description: Domain record management and IP updates
+
+security:
+ - BearerAuth: []
+
+paths:
+ /status:
+ get:
+ tags:
+ - Health
+ summary: Check API status
+ description: Returns the current status of the Dyno API
+ operationId: getStatus
+ security: []
+ responses:
+ '200':
+ description: API is running
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StatusResponse'
+
+ /auth/signup:
+ post:
+ tags:
+ - Authentication
+ summary: Sign up a new user
+ description: Create a new user account with username, email, and password
+ operationId: signUp
+ security: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SignUpRequest'
+ responses:
+ '201':
+ description: User created successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ $ref: '#/components/schemas/SignUpResponse'
+ '400':
+ description: Invalid request or validation error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /auth/login:
+ post:
+ tags:
+ - Authentication
+ summary: Login user
+ description: Authenticate a user with username/email and password
+ operationId: login
+ security: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginRequest'
+ responses:
+ '200':
+ description: Login successful
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ $ref: '#/components/schemas/LoginResponse'
+ '400':
+ description: Invalid credentials or validation error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /user/profile:
+ get:
+ tags:
+ - User
+ summary: Get user profile
+ description: Retrieve the authenticated user's profile information
+ operationId: getUserProfile
+ security:
+ - BearerAuth: []
+ responses:
+ '200':
+ description: User profile retrieved successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ $ref: '#/components/schemas/User'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /domains:
+ get:
+ tags:
+ - Domains
+ summary: List all domains
+ description: Retrieve all domain records for the authenticated user
+ operationId: listDomains
+ security:
+ - BearerAuth: []
+ responses:
+ '200':
+ description: Domains retrieved successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/DomainRecord'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /domain:
+ post:
+ tags:
+ - Domains
+ summary: Create a new domain
+ description: Create a new domain record with initial IP addresses
+ operationId: createDomain
+ security:
+ - BearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateDomainRequest'
+ responses:
+ '201':
+ description: Domain created successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ type: object
+ properties:
+ domain_id:
+ type: string
+ description: The ID of the created domain
+ '400':
+ description: Invalid request or validation error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /domain/update:
+ get:
+ tags:
+ - Domains
+ summary: Update domain by name (CLI/Script friendly)
+ description: |
+ Update domain IP addresses using query parameters.
+ This endpoint is designed for easy use with curl or scripts.
+ operationId: updateDomainByName
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: domain_name
+ in: query
+ required: true
+ description: The domain name to update
+ schema:
+ type: string
+ example: example.com
+ - name: ip_v4
+ in: query
+ required: false
+ description: New IPv4 address
+ schema:
+ type: string
+ format: ipv4
+ example: 192.168.1.1
+ - name: ip_v6
+ in: query
+ required: false
+ description: New IPv6 address
+ schema:
+ type: string
+ format: ipv6
+ example: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+ responses:
+ '200':
+ description: Domain updated successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ type: string
+ example: OK
+ '400':
+ description: Invalid request parameters
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /domain/config:
+ get:
+ tags:
+ - Domains
+ summary: Get domain configuration
+ description: Retrieve configuration information about the domain service
+ operationId: getDomainConfig
+ security:
+ - BearerAuth: []
+ responses:
+ '200':
+ description: Domain configuration retrieved successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ type: object
+ description: Domain configuration details
+
+ /domain/{domainId}:
+ put:
+ tags:
+ - Domains
+ summary: Update domain by ID (RESTful)
+ description: Update domain IP addresses using the domain ID (RESTful API style)
+ operationId: updateDomainById
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: domainId
+ in: path
+ required: true
+ description: The domain ID to update
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateDomainRequest'
+ responses:
+ '200':
+ description: Domain updated successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ type: string
+ example: OK
+ '400':
+ description: Invalid request or validation error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '404':
+ description: Domain not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ delete:
+ tags:
+ - Domains
+ summary: Delete domain
+ description: Delete a domain record by its ID
+ operationId: deleteDomain
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: domainId
+ in: path
+ required: true
+ description: The domain ID to delete
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Domain deleted successfully
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/SuccessResponse'
+ - type: object
+ properties:
+ data:
+ type: string
+ example: OK
+ '400':
+ description: Invalid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '404':
+ description: Domain not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ description: JWT token for authentication. Use the token received from login/signup endpoints.
+
+ schemas:
+ StatusResponse:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ message:
+ type: string
+ example: Dyno API is running
+ data:
+ type: object
+ properties:
+ status:
+ type: string
+ example: ok
+
+ SuccessResponse:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ message:
+ type: string
+ example: Operation completed successfully
+ data:
+ type: object
+ description: Response data (varies by endpoint)
+
+ ErrorResponse:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: false
+ message:
+ type: string
+ example: An error occurred
+ error:
+ type: object
+ description: Error details
+
+ SignUpRequest:
+ type: object
+ required:
+ - username
+ - email
+ - password
+ properties:
+ username:
+ type: string
+ minLength: 3
+ maxLength: 50
+ example: johndoe
+ description: Unique username for the account
+ email:
+ type: string
+ format: email
+ example: john.doe@example.com
+ description: Valid email address
+ password:
+ type: string
+ format: password
+ minLength: 8
+ example: SecureP@ssw0rd
+ description: Password must be at least 8 characters
+
+ SignUpResponse:
+ type: object
+ properties:
+ userId:
+ type: string
+ example: "12345"
+ description: The ID of the newly created user
+ token:
+ type: string
+ example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+ description: JWT authentication token
+
+ LoginRequest:
+ type: object
+ required:
+ - identifier
+ - password
+ properties:
+ identifier:
+ type: string
+ example: johndoe
+ description: Username or email address
+ password:
+ type: string
+ format: password
+ example: SecureP@ssw0rd
+ description: User's password
+
+ LoginResponse:
+ type: object
+ properties:
+ userId:
+ type: string
+ example: "12345"
+ description: The user's ID
+ token:
+ type: string
+ example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+ description: JWT authentication token
+
+ User:
+ type: object
+ properties:
+ id:
+ type: string
+ example: "12345"
+ description: User's unique identifier
+ username:
+ type: string
+ example: johndoe
+ description: User's username
+ email:
+ type: string
+ format: email
+ example: john.doe@example.com
+ description: User's email address
+ token:
+ type: string
+ example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+ description: User's authentication token
+ created_at:
+ type: string
+ format: date-time
+ example: "2025-01-01T12:00:00Z"
+ description: Account creation timestamp
+ updated_at:
+ type: string
+ format: date-time
+ example: "2025-01-10T12:00:00Z"
+ description: Account last update timestamp
+
+ DomainRecord:
+ type: object
+ properties:
+ id:
+ type: string
+ example: "67890"
+ description: Domain record's unique identifier
+ userId:
+ type: string
+ example: "12345"
+ description: ID of the user who owns this domain
+ domainName:
+ type: string
+ example: example.com
+ description: The domain name
+ currentIPV4:
+ type: string
+ format: ipv4
+ example: 192.168.1.1
+ description: Current IPv4 address
+ currentIPV6:
+ type: string
+ format: ipv6
+ example: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+ description: Current IPv6 address
+ createdAt:
+ type: string
+ format: date-time
+ example: "2025-01-01T12:00:00Z"
+ description: Record creation timestamp
+ updatedAt:
+ type: string
+ format: date-time
+ example: "2025-01-10T12:00:00Z"
+ description: Record last update timestamp
+
+ CreateDomainRequest:
+ type: object
+ required:
+ - domainName
+ - IPV4
+ properties:
+ domainName:
+ type: string
+ example: example.com
+ description: The domain name to create
+ IPV4:
+ type: string
+ format: ipv4
+ example: 192.168.1.1
+ description: Initial IPv4 address
+ IPV6:
+ type: string
+ format: ipv6
+ example: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+ description: Initial IPv6 address (optional)
+
+ UpdateDomainRequest:
+ type: object
+ required:
+ - newIPV4
+ properties:
+ newIPV4:
+ type: string
+ format: ipv4
+ example: 192.168.1.100
+ description: New IPv4 address
+ newIPV6:
+ type: string
+ format: ipv6
+ example: 2001:0db8:85a3:0000:0000:8a2e:0370:7335
+ description: New IPv6 address (optional)
+
diff --git a/taskfile.yml b/taskfile.yml
index 029077f..1864086 100644
--- a/taskfile.yml
+++ b/taskfile.yml
@@ -160,7 +160,8 @@ tasks:
--build-arg ENV=development
--build-arg VERSION={{.VERSION}}
--build-arg BUILD_TIME={{.BUILD_TIME}}
- --build-arg COMMIT_HASH={{.GIT_COMMIT}}
+ --build-arg COMMIT_HASH={{.GIT_COMMIT}}
+ --build-arg HOST={{0.0.0.0}}
-t {{.APP_NAME}}:{{.VERSION}} .
docker-run: