Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,129 @@
# T.O.P. (Topology Orchestration Platform)
# TOPHAT

## Installation

### STEP 1 - Interconnects

### Supported Platforms
Interconnect devices must meet the following requirements to ensure compatibility with TOPHAT:

**Operating System (OS)**

- Cisco IOSv
- Cisco IOS
- Cisco IOS-XE

**Port Density**

TOPHAT can support up to 2 Interconnect devices, each with varying port densities:

- **1x Interconnects**
- Must be 48 ports
- **2x Interconnects**
- Interconnect 1: 48 ports
- Interconnect 2: 24-48 ports

---

#### Initial Configuration

Interconnect devices must be **remotely accessible** via **SSH** from the **out-of-band (OOB) management interface** to the TOPHAT application host.

It is recommended to use spanning-tree mode MST, and static assign OOB IP addresses for the Interconnects.

---

##### Authentication

- **SSH Access**: Required for secure remote administration.
- **User Authentication**: Devices must support **username/password authentication**.
- **Privilege Escalation**: An **enable secret password** must be configured for administrative access.

An example basic configuration is provided below:

```
hostname Interconnect
ip domain-name interconnect.lab
username admin privilege 15 secret 0 cisco
enable secret cisco
line vty 0 15
login local
transport input ssh
crypto key generate rsa modulus 2048
ip ssh version 2
```

---

##### Lab Device Ports

All device interfaces (**excluding the last four ports**) must be configured for **dot1q tunneling (QinQ)** to encapsulate Layer 2 protocol frames. These ports serve as direct connections to lab devices.

```
interface range GigabitEthernet1/0/1-44
shutdown
no switchport access vlan
switchport mode dot1q-tunnel
negotiation auto
mtu 9000
mtu 8978
l2protocol-tunnel cdp
l2protocol-tunnel lldp
l2protocol-tunnel stp
l2protocol-tunnel vtp
l2protocol-tunnel point-to-point pagp
l2protocol-tunnel point-to-point lacp
l2protocol-tunnel point-to-point udld
no cdp enable
```

---

##### Transport Ports

The last four interfaces (**45-48** or **21-24**, depending on the platform) are dedicated to **transporting traffic between Interconnects**.

*If you are only using one Interconnect, shut these ports.*

```
port-channel load-balance src-dst-mac
!
interface GigabitEthernet1/0/45-48
channel-protocol lacp
channel-group 1 mode active
no shutdown
!
interface Port-channel1
switchport mode trunk
switchport trunk allowed vlan all
switchport nonegotiate
mtu 9000
mtu 8978
no cdp enable
no shutdown
```

### STEP 2 - Application

#### Environment Setup

Once the Interconnects are configured, proceed with the installation of the TOPHAT application.

Navigate to the [TOPHAT GitHub](https://github.com/breyr/TOPHAT/blob/main/compose.prod.yaml) repository, and make a copy of the `compose.prod.yaml` file.

Save the file as `docker-compose.yml` in your desired directory.

#### Running the Application

Run the following command to start the application:

```sh
docker-compose -f docker-compose.yml up -d
```

TOPHAT will now be running at [0.0.0.0:80](0.0.0.0:80).

To expose TOPHAT outside of your LAN, we recommend using [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to securely expose the UI externally with ZeroTrust.

## Developing

Expand Down
2 changes: 1 addition & 1 deletion backend/src/controllers/DeviceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class DeviceController {
try {
const { id } = req.params;
// will have a payload user id otherwise this request is not authenticated through middleware
const device = await this.deviceService.unbookDevice(parseInt(id), req.jwt_payload?.id!);
const device = await this.deviceService.unbookDevice(parseInt(id), req.jwt_payload?.id!, req.jwt_payload?.accountType!);

// we successfully unbooked the device
if (device) {
Expand Down
8 changes: 4 additions & 4 deletions backend/src/repositories/PrismaDeviceRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DeviceType, IconType, PrismaClient, type Device } from "@prisma/client";
import { AccountType, DeviceType, IconType, PrismaClient, type Device } from "@prisma/client";
import bcrypt from 'bcryptjs';
import { IDeviceRepository } from "../types/classInterfaces";

Expand Down Expand Up @@ -108,16 +108,16 @@ export class PrismaDeviceRepository implements IDeviceRepository {
});
}

async unbookDevice(deviceId: number, userId: number): Promise<Device | null> {
async unbookDevice(deviceId: number, userId: number, accountType: AccountType): Promise<Device | null> {
return await this.prisma.$transaction(async (tx) => {
// check if device is already booked
const current = await tx.device.findUnique({
where: { id: deviceId },
select: { userId: true }
});

// only allow unbooking of device if userIds match
if (current?.userId !== userId) {
// only allow unbooking of device if userIds match AND account type is not admin or owner
if (accountType !== 'ADMIN' && accountType !== 'OWNER' && current?.userId !== userId) {
throw new Error("UNAUTHORIZED");
}

Expand Down
6 changes: 3 additions & 3 deletions backend/src/services/DeviceService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Device, DeviceType, IconType } from "@prisma/client";
import type { AccountType, Device, DeviceType, IconType } from "@prisma/client";
import { IDeviceRepository, IDeviceService } from "../types/classInterfaces";

export class DeviceService implements IDeviceService {
Expand Down Expand Up @@ -58,7 +58,7 @@ export class DeviceService implements IDeviceService {
return this.deviceRepository.bookDevice(deviceId, userId);
}

async unbookDevice(deviceId: number, userId: number): Promise<Device | null> {
return this.deviceRepository.unbookDevice(deviceId, userId);
async unbookDevice(deviceId: number, userId: number, accountType: AccountType): Promise<Device | null> {
return this.deviceRepository.unbookDevice(deviceId, userId, accountType);
}
}
6 changes: 3 additions & 3 deletions backend/src/types/classInterfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// holds interfaces similar to C#
import { AppConfig, AppUser, Connection, Device, DeviceType, IconType, Topology } from "@prisma/client";
import { AccountType, AppConfig, AppUser, Connection, Device, DeviceType, IconType, Topology } from "@prisma/client";
import type { CreateConnectionRequestPayload, CreateTopologyRequestPayload, RegisterUserRequestPayload } from "common";
import { UpdateTopologyDTO } from "./types";

Expand Down Expand Up @@ -55,7 +55,7 @@ export interface IDeviceRepository {
findByType(deviceType: DeviceType): Promise<Device[]>;
findByIcon(deviceIcon: IconType): Promise<Device[]>;
bookDevice(deviceId: number, userId: number): Promise<Device | null>;
unbookDevice(deviceId: number, userId: number): Promise<Device | null>;
unbookDevice(deviceId: number, userId: number, accountType: AccountType): Promise<Device | null>;
}

export interface IDeviceService {
Expand All @@ -69,7 +69,7 @@ export interface IDeviceService {
getDevicesByType(deviceType: DeviceType): Promise<Device[]>;
getDevicesByIcon(deviceIcon: IconType): Promise<Device[]>;
bookDevice(deviceId: number, userId: number): Promise<Device | null>;
unbookDevice(deviceId: number, userId: number): Promise<Device | null>;
unbookDevice(deviceId: number, userId: number, accountType: AccountType): Promise<Device | null>;
}

export interface IConnectionRepository {
Expand Down
6 changes: 3 additions & 3 deletions compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ services:
- postgres_data:/var/lib/postgresql/data

backend:
image: breyr/top-backend:1.0.0
image: breyr/top-backend:1.0.1
container_name: backend
environment:
DATABASE_URL: postgres://demo:demo@postgres:5432/demo
Expand All @@ -25,15 +25,15 @@ services:
- postgres

frontend:
image: breyr/top-frontend:1.0.0
image: breyr/top-frontend:1.0.1
container_name: frontend
ports:
- "80:80"
depends_on:
- backend

interconnect-api:
image: breyr/top-interconnectapi:1.0.0
image: breyr/top-interconnectapi:1.0.1
container_name: interconnect-api
environment:
SECRET_KEY: your_secret
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^6.1.1",
"vite": "^6.2.4",
"vite-plugin-svgr": "^4.3.0"
}
}
15 changes: 6 additions & 9 deletions frontend/src/components/TopologyCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Topology } from 'common';
import { Image, Trash } from "lucide-react";
import { Archive, Image, Trash } from "lucide-react";
import React, { useEffect, useState } from 'react';
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth.ts";
Expand All @@ -21,15 +21,13 @@ const TopologyCard: React.FC<TopologyProps> = ({
onDelete,
onArchive,
readOnly,
userId
}) => {
const { menuOpen, hideMenu } = useContextMenu();
const { user } = useAuth();
const navigateTo = useNavigate();
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
const [archived, setArchived] = useState(initialArchived);
const [isModalOpen, setIsModalOpen] = useState(false);
const ownsTopology = user?.id === userId;

const handleClick = (event: React.MouseEvent) => {
if (archived) return;
Expand Down Expand Up @@ -87,9 +85,9 @@ const TopologyCard: React.FC<TopologyProps> = ({
return (
<div className="relative group">
<button
onClick={handleDeleteClick}
className="hidden group-hover:block absolute top-0 right-[-20px] m-1 p-2 shadow-md text-red-500 bg-gray-50 rounded-full hover:bg-gray-100 z-10">
<Trash size={16} />
onClick={(e) => archived ? handleDeleteClick(e) : toggleArchived(e)}
className="hidden group-hover:block absolute top-0 right-[-20px] m-1 p-2 shadow-md bg-gray-50 rounded-full hover:bg-gray-100 z-10">
{archived ? <Trash size={16} className='text-red-500' /> : <Archive size={16} className='text-amber-500' />}
</button>
<div
key={id}
Expand Down Expand Up @@ -119,10 +117,9 @@ const TopologyCard: React.FC<TopologyProps> = ({
</p>
</div>
<span
onClick={!ownsTopology ? undefined : toggleArchived}
className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full ${archived
? `bg-red-100 text-red-700 ${!ownsTopology ? "cursor-default" : "hover:bg-red-300 cursor-pointer"}`
: `bg-green-100 text-green-700 ${!ownsTopology ? "cursor-default" : "hover:bg-green-300 cursor-pointer"}`
? `bg-red-100 text-red-700`
: `bg-green-100 text-green-700`
}`}
>
{archived ? "Archived" : "Active"}
Expand Down
Loading