├── log └── .placeholder ├── docker ├── README.md ├── create_db.sql ├── Dockerfile └── docker-compose.yml ├── t ├── checkers │ ├── timeout.pl │ ├── down.pl │ └── up.pl ├── util.t └── basic.t ├── .gitignore ├── public ├── logo.png ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 └── css │ ├── default.css │ ├── bootstrap-theme.css │ └── bootstrap-theme.css.map ├── .gitattributes ├── ansible ├── roles │ ├── monitoring │ │ ├── templates │ │ │ ├── .env.j2 │ │ │ ├── prometheus-servers.j2 │ │ │ ├── prometheus-config.yml.j2 │ │ │ ├── docker-compose.yml.j2 │ │ │ └── queries.yaml │ │ └── tasks │ │ │ └── main.yml │ ├── web │ │ ├── handlers │ │ │ └── main.yml │ │ ├── templates │ │ │ ├── nginx.conf.j2 │ │ │ └── cs.nginx.conf.j2 │ │ └── tasks │ │ │ └── main.yml │ ├── db │ │ ├── handlers │ │ │ └── main.yml │ │ ├── templates │ │ │ └── pg_cs.conf.j2 │ │ └── tasks │ │ │ └── main.yml │ └── common │ │ ├── templates │ │ └── node-compose.yml.j2 │ │ └── tasks │ │ ├── node_exporter.yml │ │ ├── main.yml │ │ └── docker.yml ├── cs-deploy.yml ├── inventory.cfg ├── group_vars │ └── all ├── cs-init.yml ├── cs-stop.yml └── cs-start.yml ├── script └── cs ├── templates ├── admin │ ├── index.html.ep │ ├── info.html.ep │ └── view.html.ep ├── main │ ├── index.html.ep │ └── team.html.ep ├── layouts │ └── default.html.ep └── scoreboard.html.ep ├── lib ├── CS │ ├── Command │ │ ├── board_reload.pm │ │ ├── board_message.pm │ │ ├── reset_db.pm │ │ ├── check_db.pm │ │ ├── add_team.pm │ │ ├── watcher.pm │ │ ├── init_db.pm │ │ └── manager.pm │ ├── Controller │ │ ├── Main.pm │ │ ├── Api.pm │ │ ├── Flags.pm │ │ └── Admin.pm │ └── Model │ │ ├── Flag.pm │ │ ├── Scoreboard.pm │ │ ├── Score.pm │ │ ├── Checker.pm │ │ └── Util.pm └── CS.pm ├── deploy ├── Dockerfile ├── README.md └── docker-compose.yml ├── cpanfile ├── LICENSE ├── cs.test.conf ├── cs.conf.example ├── .github └── workflows │ └── ci.yml ├── CONFIGURE.md ├── README.md └── cs.sql /log/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | These files are used only for development. 2 | -------------------------------------------------------------------------------- /t/checkers/timeout.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | sleep 50; 4 | -------------------------------------------------------------------------------- /docker/create_db.sql: -------------------------------------------------------------------------------- 1 | create database cs_test owner postgres; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | c_s.conf 2 | *.log 3 | *.pid 4 | *.bak 5 | *.swp 6 | .env 7 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackerDom/checksystem/HEAD/public/logo.png -------------------------------------------------------------------------------- /t/checkers/down.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | print "some error 😉\n"; 4 | exit 104; 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pl linguist-language=Perl 2 | *.pm linguist-language=Perl 3 | *.t linguist-language=Perl 4 | -------------------------------------------------------------------------------- /ansible/roles/monitoring/templates/.env.j2: -------------------------------------------------------------------------------- 1 | GF_SECURITY_ADMIN_PASSWORD={{ grafana_admin_pass | default('Passw0rd!') }} 2 | -------------------------------------------------------------------------------- /ansible/roles/web/handlers/main.yml: -------------------------------------------------------------------------------- 1 | - name: restart nginx 2 | systemd: 3 | name: nginx 4 | state: restarted 5 | -------------------------------------------------------------------------------- /ansible/roles/db/handlers/main.yml: -------------------------------------------------------------------------------- 1 | - name: restart postgresql 2 | systemd: 3 | name: postgresql@15-main 4 | state: restarted 5 | -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackerDom/checksystem/HEAD/public/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackerDom/checksystem/HEAD/public/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackerDom/checksystem/HEAD/public/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackerDom/checksystem/HEAD/public/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /script/cs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use lib 'lib'; 7 | 8 | require Mojolicious::Commands; 9 | Mojolicious::Commands->start_app('CS'); 10 | -------------------------------------------------------------------------------- /templates/admin/index.html.ep: -------------------------------------------------------------------------------- 1 | % layout 'default'; 2 | % title '[admin] ' . app->ctf_name; 3 | 4 | % content_for r => begin 5 | Round <%= $round %> 6 | % end 7 | 8 | %= include 'scoreboard'; 9 | -------------------------------------------------------------------------------- /ansible/roles/monitoring/templates/prometheus-servers.j2: -------------------------------------------------------------------------------- 1 | # {{ ansible_managed }} 2 | 3 | - targets: 4 | {% for item in groups['cs'] %} 5 | - {{ hostvars[item]['private_ip'] }}:9100 6 | {% endfor %} 7 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM perl:5.40 2 | 3 | ADD cpanfile / 4 | 5 | RUN cpanm -n --installdeps / 6 | RUN cpanm -n DDP 7 | 8 | RUN apt-get update && apt-get install -y less 9 | 10 | WORKDIR /app 11 | COPY . /app 12 | 13 | CMD ["/bin/bash"] 14 | -------------------------------------------------------------------------------- /t/checkers/up.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | my $command = shift; 4 | if ($command eq 'info') { 5 | print "vulns: 1:1:2\npublic_flag_description: user profile\n"; 6 | } else { 7 | print '{"public_flag_id":"911","password":"sEcr3t"}'; 8 | } 9 | exit 101; 10 | -------------------------------------------------------------------------------- /ansible/cs-deploy.yml: -------------------------------------------------------------------------------- 1 | - hosts: cs 2 | roles: 3 | - common 4 | 5 | - hosts: monitoring 6 | roles: 7 | - role: monitoring 8 | tags: monitoring 9 | 10 | - hosts: master 11 | roles: 12 | - web 13 | 14 | - hosts: db 15 | roles: 16 | - db 17 | -------------------------------------------------------------------------------- /lib/CS/Command/board_reload.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::board_reload; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | has description => 'Send reload to scoreboard via API'; 5 | 6 | sub run { 7 | my $app = shift->app; 8 | 9 | $app->pg->pubsub->notify('reload'); 10 | } 11 | 12 | 1; 13 | -------------------------------------------------------------------------------- /ansible/roles/db/templates/pg_cs.conf.j2: -------------------------------------------------------------------------------- 1 | listen_addresses = '127.0.0.1, {{ pg_cs_host }}' 2 | max_connections = {{ pg_max_connections }} 3 | shared_buffers = {{ pg_shared_buffers }} 4 | work_mem = {{ pg_work_mem }} 5 | random_page_cost = {{ pg_random_page_cost }} 6 | shared_preload_libraries = 'pg_stat_statements' 7 | -------------------------------------------------------------------------------- /lib/CS/Command/board_message.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::board_message; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | has description => 'Send message to scoreboard via API'; 5 | 6 | sub run { 7 | my $app = shift->app; 8 | my $msg = shift; 9 | 10 | $app->pg->pubsub->notify(message => $msg); 11 | } 12 | 13 | 1; 14 | -------------------------------------------------------------------------------- /ansible/roles/common/templates/node-compose.yml.j2: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | services: 4 | node_exporter: 5 | image: prom/node-exporter:latest 6 | command: 7 | - "--path.rootfs=/host" 8 | - "--web.listen-address={{ private_ip }}:9100" 9 | restart: unless-stopped 10 | network_mode: host 11 | pid: host 12 | volumes: 13 | - "/:/host:ro,rslave" 14 | -------------------------------------------------------------------------------- /lib/CS/Command/reset_db.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::reset_db; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | has description => 'Reset db'; 5 | 6 | sub run { 7 | my $app = shift->app; 8 | 9 | # Jobs 10 | $app->minion->reset({all => 1}); 11 | 12 | # Migrations 13 | $app->pg->migrations->active; 14 | $app->pg->migrations->name('cs')->migrate(0)->migrate; 15 | } 16 | 17 | 1; 18 | -------------------------------------------------------------------------------- /templates/admin/info.html.ep: -------------------------------------------------------------------------------- 1 | % layout 'default'; 2 | % title '[admin] ' . app->ctf_name; 3 | 4 |
5 |
6 |

Game Status on <%= $now %>

7 |
<%= $game_status %>
8 |
9 | 10 | % for my $row (@$tables) { 11 |
12 |

<%= $row->{name} %>

13 |
<%= $row->{data} %>
14 |
15 | % } 16 |
17 | -------------------------------------------------------------------------------- /ansible/inventory.cfg: -------------------------------------------------------------------------------- 1 | [master] 2 | master ansible_host=127.0.0.2 ansible_user=root 3 | 4 | [flags] 5 | flags ansible_host=127.0.0.3 ansible_user=root 6 | 7 | [db] 8 | db ansible_host=127.0.0.4 ansible_user=root 9 | 10 | [checkers] 11 | c1 ansible_host=127.0.0.5 ansible_user=root 12 | c2 ansible_host=127.0.0.6 ansible_user=root 13 | c3 ansible_host=127.0.0.7 ansible_user=root 14 | 15 | [monitoring] 16 | m1 ansible_host=127.0.1.1 ansible_user=root 17 | 18 | [cs:children] 19 | master 20 | flags 21 | db 22 | checkers 23 | monitoring 24 | -------------------------------------------------------------------------------- /lib/CS/Command/check_db.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::check_db; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | has description => 'Check db for init game'; 5 | 6 | sub run { 7 | my $app = shift->app; 8 | 9 | my $round = $app->pg->db->select(rounds => 'count(*)')->array->[0]; 10 | die "There is no data in rounds table" unless $round; 11 | 12 | my $services = $app->pg->db->select(services => 'count(*)')->array->[0]; 13 | die "Services in config and db are different" unless $services == @{$app->config->{services}}; 14 | } 15 | 16 | 1; 17 | -------------------------------------------------------------------------------- /ansible/roles/common/tasks/node_exporter.yml: -------------------------------------------------------------------------------- 1 | - name: node_exporter catalog 2 | file: 3 | path: /root/node_exporter 4 | state: directory 5 | mode: 0755 6 | owner: root 7 | group: root 8 | 9 | - name: docker compose 10 | template: 11 | src: node-compose.yml.j2 12 | dest: /root/node_exporter/docker-compose.yml 13 | mode: 0644 14 | owner: root 15 | group: root 16 | 17 | - name: ensure compose running 18 | docker_compose: 19 | project_src: /root/node_exporter 20 | state: present 21 | restarted: yes 22 | -------------------------------------------------------------------------------- /t/util.t: -------------------------------------------------------------------------------- 1 | use Mojo::Base -strict; 2 | 3 | use Test::Mojo; 4 | use Test::More; 5 | 6 | BEGIN { $ENV{MOJO_CONFIG} = 'cs.test.conf' } 7 | 8 | my $t = Test::Mojo->new('CS'); 9 | my $app = $t->app; 10 | my $u = $app->model('util'); 11 | 12 | # game status 13 | my ($status, $round) = $u->game_status; 14 | $status >= 0 ? pass('right status') : fail('right status'); 15 | 16 | my $game_time = $u->game_time; 17 | ok $game_time->{start} > 0, 'right game time'; 18 | ok $game_time->{end} > 0, 'right game time'; 19 | 20 | ok $u->game_duration > 0, 'rigth game duration'; 21 | 22 | done_testing; 23 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | cs: 5 | build: 6 | context: .. 7 | dockerfile: ./docker/Dockerfile 8 | image: "cs:latest" 9 | ports: 10 | - "3000:3000" 11 | stdin_open: true 12 | tty: true 13 | depends_on: 14 | - pg 15 | volumes: 16 | - "../:/app" 17 | environment: 18 | - POSTGRES_PASSWORD 19 | - POSTGRES_URI 20 | pg: 21 | image: "postgres" 22 | volumes: 23 | - "cs_pg_data:/var/lib/postgresql/data" 24 | - "./create_db.sql:/docker-entrypoint-initdb.d/create_db.sql" 25 | environment: 26 | - POSTGRES_PASSWORD 27 | 28 | volumes: 29 | cs_pg_data: 30 | -------------------------------------------------------------------------------- /ansible/roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: docker 2 | include_tasks: docker.yml 3 | 4 | - name: user 5 | user: 6 | name: "{{ cs_user }}" 7 | groups: docker 8 | append: yes 9 | state: present 10 | 11 | - name: system requirements 12 | apt: 13 | name: 14 | - atop 15 | - make 16 | - sudo 17 | - rsync 18 | state: latest 19 | update_cache: yes 20 | 21 | - name: node_exporter 22 | include_tasks: 23 | file: node_exporter.yml 24 | apply: 25 | tags: node_exporter 26 | tags: node_exporter 27 | 28 | - name: pull cs image 29 | docker_image: 30 | name: "{{ cs_docker_image }}" 31 | source: pull 32 | force_source: yes 33 | tags: update 34 | -------------------------------------------------------------------------------- /ansible/roles/web/templates/nginx.conf.j2: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes {{cs_nginx_workers}}; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 16 | '$status $body_bytes_sent "$http_referer" ' 17 | '"$http_user_agent" "$http_x_forwarded_for"'; 18 | 19 | access_log /var/log/nginx/access.log main; 20 | 21 | sendfile on; 22 | keepalive_timeout 65; 23 | 24 | include /etc/nginx/conf.d/*.conf; 25 | } 26 | -------------------------------------------------------------------------------- /deploy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as scoreboard 2 | 3 | RUN git clone https://github.com/HackerDom/ctf-scoreboard-client.git /repo 4 | WORKDIR /repo/scoreboard 5 | 6 | RUN npm install 7 | RUN npm run build 8 | 9 | FROM ghcr.io/hackerdom/checksystem:master 10 | 11 | COPY --from=scoreboard /repo/scoreboard/build /scoreboard 12 | ENV CS_STATIC=/scoreboard 13 | 14 | # Install checker's dependencies for current CTF. 15 | # For example, you can use Gornilo library for simple 16 | # way to write checkers (https://github.com/HackerDom/Gornilo) 17 | 18 | RUN apt-get update 19 | RUN apt-get install -y python3-pip 20 | RUN pip install gornilo 21 | 22 | # Copy checkers to /app/checkers catalog 23 | # COPY checkers /app/checker 24 | -------------------------------------------------------------------------------- /ansible/roles/monitoring/templates/prometheus-config.yml.j2: -------------------------------------------------------------------------------- 1 | global: 2 | evaluation_interval: 15s 3 | scrape_interval: 15s 4 | scrape_timeout: 10s 5 | 6 | scrape_configs: 7 | - job_name: prometheus 8 | metrics_path: /metrics 9 | static_configs: 10 | - targets: 11 | - 127.0.0.1:9090 12 | 13 | - job_name: node 14 | static_configs: 15 | - targets: 16 | {% for item in groups['cs'] -%} 17 | - {{ hostvars[item]['private_ip'] }}:9100 18 | {% endfor %} 19 | 20 | - job_name: nginx 21 | static_configs: 22 | - targets: 23 | - nginx-exporter:9113 24 | 25 | - job_name: postgres 26 | static_configs: 27 | - targets: 28 | - postgres-exporter:9187 29 | -------------------------------------------------------------------------------- /ansible/group_vars/all: -------------------------------------------------------------------------------- 1 | cs_user: cs 2 | cs_docker_image: ghcr.io/hackerdom/checksystem:master 3 | 4 | pg_cs_user: cs 5 | pg_cs_pass: qwer 6 | pg_cs_db: cs 7 | pg_cs_host: 127.0.0.1 8 | pg_cs_port: 5432 9 | pg_max_connections: 1024 10 | pg_shared_buffers: 1GB 11 | pg_work_mem: 256MB 12 | pg_random_page_cost: 1.1 13 | 14 | cs_limit_nofile: 10000 15 | 16 | cs_worker_default_jobs: 4 17 | cs_worker_checkers_jobs: 3 18 | cs_worker_checkers_queues: -q checker 19 | cs_worker_instance: 1 20 | 21 | cs_hypnotoad_listen: 127.0.0.1:8080 22 | cs_hypnotoad_flags_listen: 23 | - 127.0.0.1:8080 24 | cs_hypnotoad_flags_port: 8080 25 | cs_nginx_workers: 1 26 | cs_nginx_upstream_keepalive: 32 27 | cs_nginx_listen: 80 28 | 29 | cs_base_url: https://example.com:8080/ 30 | -------------------------------------------------------------------------------- /cpanfile: -------------------------------------------------------------------------------- 1 | requires 'Cpanel::JSON::XS' => '4.25'; 2 | requires 'DBD::Pg' => '3.15.0'; 3 | requires 'EV' => '4.33'; 4 | requires 'IO::Socket::SSL' => '2.071'; 5 | requires 'IPC::Run' => '20200505'; 6 | requires 'List::Util' => '1.55'; 7 | requires 'Minion' => '10.22'; 8 | requires 'Mojo::Pg' => '4.25'; 9 | requires 'Mojolicious' => '9.19'; 10 | requires 'Mojolicious::Plugin::Model' => '0.11'; 11 | requires 'Net::DNS::Native' => '0.22'; 12 | requires 'Proc::Killfam' => '0.59'; 13 | requires 'Sereal::Dclone' => '0.003'; 14 | requires 'String::Random' => '0.31'; 15 | -------------------------------------------------------------------------------- /ansible/cs-init.yml: -------------------------------------------------------------------------------- 1 | - hosts: master 2 | tasks: 3 | - name: reset db 4 | docker_container: 5 | name: cs_reset_db 6 | image: "{{ cs_docker_image }}" 7 | command: "perl script/cs reset_db" 8 | container_default_behavior: no_defaults 9 | restart_policy: "no" 10 | detach: "no" 11 | env: 12 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 13 | - name: init db 14 | docker_container: 15 | name: cs_init_db 16 | image: "{{ cs_docker_image }}" 17 | command: "perl script/cs init_db" 18 | container_default_behavior: no_defaults 19 | restart_policy: "no" 20 | detach: "no" 21 | env: 22 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 23 | -------------------------------------------------------------------------------- /ansible/cs-stop.yml: -------------------------------------------------------------------------------- 1 | - hosts: flags 2 | tasks: 3 | - name: stop web flags 4 | docker_container: 5 | name: cs_web_flags 6 | state: stopped 7 | 8 | - hosts: checkers 9 | tasks: 10 | - name: stop checkers 11 | docker_container: 12 | name: "cs_checker_worker_{{ item }}" 13 | state: stopped 14 | with_sequence: count={{ cs_worker_instance }} 15 | 16 | - hosts: master 17 | tasks: 18 | - name: stop manager 19 | docker_container: 20 | name: cs_manager 21 | state: stopped 22 | 23 | - name: stop default worker 24 | docker_container: 25 | name: cs_default_worker 26 | state: stopped 27 | 28 | - name: stop watcher 29 | docker_container: 30 | name: cs_watcher 31 | state: stopped 32 | 33 | - name: stop web 34 | docker_container: 35 | name: cs_web 36 | state: stopped 37 | -------------------------------------------------------------------------------- /ansible/roles/common/tasks/docker.yml: -------------------------------------------------------------------------------- 1 | - name: remove old docker packages 2 | apt: 3 | name: 4 | - docker 5 | - docker-engine 6 | - docker.io 7 | - containerd 8 | - runc 9 | state: absent 10 | 11 | - name: apt keys 12 | apt_key: url=https://download.docker.com/linux/{{ ansible_distribution|lower }}/gpg 13 | 14 | - name: docker repo 15 | apt_repository: 16 | repo: "deb [arch=amd64] https://download.docker.com/linux/{{ ansible_distribution|lower }} {{ ansible_distribution_release }} stable" 17 | filename: docker 18 | 19 | - name: ensure docker packages 20 | apt: 21 | name: 22 | - docker-ce 23 | - docker-ce-cli 24 | - containerd.io 25 | state: latest 26 | update_cache: yes 27 | 28 | - name: pip 29 | apt: 30 | name: 31 | - python3-pip 32 | - python3-setuptools 33 | install_recommends: no 34 | 35 | - name: docker modules for python 36 | pip: 37 | name: 38 | - docker 39 | - docker-compose 40 | executable: pip3 41 | -------------------------------------------------------------------------------- /ansible/roles/monitoring/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: monitoring catalogs 2 | file: 3 | path: "{{ item.path }}" 4 | state: directory 5 | mode: "{{ item.mode | default('0755') }}" 6 | owner: root 7 | group: root 8 | with_items: 9 | - { path: "/root/monitoring" } 10 | - { path: "/root/monitoring/etc" } 11 | - { path: "/root/monitoring/data" } 12 | - { path: "/root/monitoring/data/prom", mode: "0777" } 13 | - { path: "/root/monitoring/data/grafana", mode: "0777" } 14 | 15 | - name: templates 16 | template: 17 | src: "{{ item.src }}" 18 | dest: "{{ item.dest }}" 19 | mode: 0644 20 | owner: root 21 | group: root 22 | with_items: 23 | - { src: "docker-compose.yml.j2", dest: "/root/monitoring/docker-compose.yml" } 24 | - { src: "prometheus-config.yml.j2", dest: "/root/monitoring/etc/prometheus.yml" } 25 | - { src: ".env.j2", dest: "/root/monitoring/.env" } 26 | - { src: "queries.yaml", dest: "/root/monitoring/etc/queries.yaml" } 27 | 28 | - name: ensure compose running 29 | docker_compose: 30 | project_src: /root/monitoring 31 | state: present 32 | restarted: yes 33 | -------------------------------------------------------------------------------- /templates/main/index.html.ep: -------------------------------------------------------------------------------- 1 | % layout 'default'; 2 | % title app->ctf_name; 3 | 4 | % content_for r => begin 5 | Round <%= $round %> 6 | % end 7 | 8 | %= include 'scoreboard'; 9 | 10 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2021 Andrey Khozov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/CS/Command/add_team.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::add_team; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | use Mojo::JSON 'j'; 5 | 6 | has description => 'Add new team to current game'; 7 | 8 | sub run { 9 | my $app = shift->app; 10 | 11 | my $team_info = j(shift); 12 | die "There is no team info details" unless $team_info; 13 | 14 | my $db = $app->pg->db; 15 | my $tx = $db->begin; 16 | 17 | my $values = { 18 | name => delete $team_info->{name}, 19 | network => delete $team_info->{network}, 20 | host => delete $team_info->{host}, 21 | token => delete $team_info->{token} 22 | }; 23 | my $details = {details => {-json => $team_info}}; 24 | my $team = $db->insert(teams => {%$values, %$details}, {returning => '*'})->expand->hash; 25 | 26 | # Scores 27 | $db->query(' 28 | insert into flag_points (round, team_id, service_id, amount) 29 | select (select max(round) from scores), ?, id, 0 from services 30 | ', $team->{id}); 31 | $db->query(' 32 | insert into sla (round, team_id, service_id, successed, failed) 33 | select (select max(round) from scores), ?, id, 0, 0 from services 34 | ', $team->{id}); 35 | 36 | $tx->commit; 37 | } 38 | 39 | 1; 40 | -------------------------------------------------------------------------------- /ansible/roles/monitoring/templates/docker-compose.yml.j2: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | services: 4 | prometheus: 5 | image: prom/prometheus:latest 6 | restart: unless-stopped 7 | volumes: 8 | - "/root/monitoring/etc/prometheus.yml:/etc/prometheus/prometheus.yml" 9 | - "/root/monitoring/data/prom:/prometheus" 10 | grafana: 11 | image: grafana/grafana:latest 12 | restart: unless-stopped 13 | volumes: 14 | - "/root/monitoring/data/grafana:/var/lib/grafana" 15 | ports: 16 | - "{{ private_ip }}:3000:3000" 17 | environment: 18 | - GF_SECURITY_ADMIN_PASSWORD 19 | nginx-exporter: 20 | image: nginx/nginx-prometheus-exporter:latest 21 | restart: unless-stopped 22 | command: "-nginx.scrape-uri=http://{{ hostvars['cs-master']['private_ip'] }}/basic_status" 23 | postgres-exporter: 24 | image: prometheuscommunity/postgres-exporter:latest 25 | restart: unless-stopped 26 | volumes: 27 | - "/root/monitoring/etc/queries.yaml:/queries.yaml" 28 | environment: 29 | DATA_SOURCE_NAME: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}:{{ pg_cs_port }}/{{ pg_cs_db }}?sslmode=disable" 30 | PG_EXPORTER_EXTEND_QUERY_PATH: "/queries.yaml" 31 | -------------------------------------------------------------------------------- /templates/layouts/default.html.ep: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= title %> 10 | 11 | 12 | 31 | <%= content %> 32 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /ansible/roles/web/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: apt keys 2 | apt_key: 3 | url: https://nginx.org/keys/nginx_signing.key 4 | 5 | - name: nginx repo source 6 | apt_repository: 7 | repo: deb http://nginx.org/packages/{{ ansible_distribution|lower }}/ {{ ansible_distribution_release }} nginx 8 | filename: nginx 9 | validate_certs: no 10 | 11 | - name: nginx 12 | apt: 13 | name: nginx 14 | state: latest 15 | update_cache: yes 16 | 17 | - name: nginx cache catalog 18 | file: 19 | path: /var/cache/nginx/cs 20 | state: directory 21 | owner: nginx 22 | group: root 23 | mode: 0700 24 | 25 | - name: nginx data catalog 26 | file: 27 | path: /var/www 28 | state: directory 29 | owner: nginx 30 | group: root 31 | mode: 0755 32 | 33 | - name: nginx config 34 | template: 35 | src: nginx.conf.j2 36 | dest: /etc/nginx/nginx.conf 37 | owner: root 38 | group: root 39 | mode: 0644 40 | notify: 41 | - restart nginx 42 | tags: update 43 | 44 | - name: nginx remove default.conf 45 | file: 46 | path: /etc/nginx/conf.d/default.conf 47 | state: absent 48 | notify: 49 | - restart nginx 50 | 51 | - name: nginx cs config 52 | template: 53 | src: cs.nginx.conf.j2 54 | dest: /etc/nginx/conf.d/cs.conf 55 | owner: root 56 | group: root 57 | mode: 0644 58 | notify: 59 | - restart nginx 60 | tags: update 61 | -------------------------------------------------------------------------------- /lib/CS/Command/watcher.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::watcher; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | has description => 'Simple check services'; 5 | 6 | sub run { 7 | my $self = shift; 8 | my $app = $self->app; 9 | 10 | Mojo::IOLoop->next_tick(sub { $self->check }); 11 | Mojo::IOLoop->recurring(30 => sub { $self->check }); 12 | 13 | Mojo::IOLoop->start; 14 | } 15 | 16 | sub check { 17 | my $app = shift->app; 18 | my $db = $app->pg->db; 19 | 20 | my $active_services = $app->model('util')->get_active_services; 21 | my $teams = $db->select('teams')->expand->hashes; 22 | 23 | for my $team (@$teams) { 24 | for my $service (values %{$app->services}) { 25 | next unless my $port = $service->{tcp_port}; 26 | 27 | if (!exists $active_services->{$service->{id}}) { 28 | next; 29 | } 30 | 31 | my $address = $app->model('util')->get_service_ip($team, $service); 32 | 33 | Mojo::IOLoop->client( 34 | {address => $address, port => $port, timeout => 10} => sub { 35 | my ($loop, $err, $stream) = @_; 36 | my $row = { 37 | team_id => $team->{id}, 38 | service_id => $service->{id}, 39 | status => ($err ? 'f' : 't'), 40 | round => \'(select max(n) from rounds)', 41 | error => $err 42 | }; 43 | $db->insert(monitor => $row); 44 | $stream->close if $stream; 45 | } 46 | ); 47 | } 48 | } 49 | } 50 | 51 | 1; 52 | -------------------------------------------------------------------------------- /ansible/roles/monitoring/templates/queries.yaml: -------------------------------------------------------------------------------- 1 | cs_round: 2 | query: select max(n) as n from rounds 3 | metrics: 4 | - n: 5 | usage: COUNTER 6 | description: Current round 7 | cs_scoreboard: 8 | query: | 9 | select t.name as team, s.score 10 | from 11 | scoreboard as s 12 | join teams as t on s.team_id = t.id 13 | where s.round = (select max(round) from scoreboard) 14 | metrics: 15 | - team: 16 | usage: LABEL 17 | - score: 18 | usage: GAUGE 19 | description: Game score 20 | cs_flags: 21 | query: | 22 | select 23 | 'installed' as type, count(*) as total from flags where ack = true 24 | union select 25 | 'stolen' as type, count(*) as total from stolen_flags 26 | metrics: 27 | - type: 28 | usage: LABEL 29 | - total: 30 | usage: COUNTER 31 | description: Total flags 32 | cs: 33 | query: | 34 | select team_id, service_id, sla, fp, flags, sflags 35 | from scores 36 | where round = (select max(round) from scores) 37 | metrics: 38 | - team_id: 39 | usage: LABEL 40 | - service_id: 41 | usage: LABEL 42 | - sla: 43 | usage: GAUGE 44 | - fp: 45 | usage: GAUGE 46 | - flags: 47 | usage: GAUGE 48 | - sflags: 49 | usage: GAUGE 50 | 51 | cs_services: 52 | query: | 53 | select service_id, round, active, phase, flag_base_amount 54 | from service_activity 55 | where round = (select max(round) from service_activity) 56 | metrics: 57 | - service_id: 58 | usage: LABEL 59 | - phase: 60 | usage: LABEL 61 | - flag_base_amount: 62 | usage: GAUGE 63 | -------------------------------------------------------------------------------- /ansible/roles/db/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: apt keys 2 | apt_key: 3 | url: https://www.postgresql.org/media/keys/ACCC4CF8.asc 4 | tags: install 5 | 6 | - name: pg repo source 7 | apt_repository: 8 | repo: deb http://apt.postgresql.org/pub/repos/apt/ {{ ansible_distribution_release }}-pgdg main 9 | filename: pgdg 10 | validate_certs: no 11 | tags: install 12 | 13 | - name: pg install 14 | apt: 15 | name: postgresql-15 16 | state: latest 17 | update_cache: yes 18 | tags: install 19 | 20 | - name: pg user 21 | shell: psql -c "create role {{ pg_cs_user }} login password '{{ pg_cs_pass }}'" 22 | become: yes 23 | become_user: postgres 24 | register: r 25 | failed_when: r.rc > 1 26 | changed_when: "'CREATE ROLE' in r.stdout" 27 | 28 | - name: pg db 29 | shell: createdb -O {{ pg_cs_user }} {{ pg_cs_db }} 30 | become: yes 31 | become_user: postgres 32 | register: r 33 | failed_when: r.rc > 1 34 | changed_when: r.rc == 0 35 | 36 | - name: pg stat statements 37 | shell: psql {{ pg_cs_db }} -c "create extension pg_stat_statements" 38 | become: yes 39 | become_user: postgres 40 | register: r 41 | failed_when: r.rc > 1 42 | changed_when: "'CREATE EXTENSION' in r.stdout" 43 | 44 | - name: pg config 45 | template: 46 | src: pg_cs.conf.j2 47 | dest: /etc/postgresql/15/main/conf.d/cs.conf 48 | notify: 49 | - restart postgresql 50 | tags: install 51 | 52 | - name: pg_hba config 53 | lineinfile: 54 | path: /etc/postgresql/15/main/pg_hba.conf 55 | insertafter: '^# IPv4 local connections:' 56 | regexp: '^host\tcs' 57 | line: "host\tcs\tcs\t0.0.0.0/0\tmd5" 58 | notify: 59 | - restart postgresql 60 | tags: install 61 | -------------------------------------------------------------------------------- /cs.test.conf: -------------------------------------------------------------------------------- 1 | { hypnotoad => {listen => ['http://127.0.0.1:8080'], workers => 8}, 2 | postgres_uri => 'postgresql://postgres:qwer@pg/cs_test', 3 | cs => { 4 | time => [['2013-01-01 00:00:00', '2013-01-01 20:00:00'], ['2013-01-02 00:00:00', '2028-03-08 23:59:59']], 5 | admin_auth => 'root:qwer', 6 | ctf_name => 'RuCTF 2015 test mode', 7 | round_length => 30, 8 | flag_life_time => 2, 9 | flags_secret => 'eiK3Oh', 10 | checkers => { 11 | hostname => sub { my ($team, $service) = @_; "$service->{name}.$team->{host}" } 12 | }, 13 | scoring => { 14 | start_flag_price => 10, 15 | heating_speed => 1/12, 16 | max_flag_price => 30, 17 | cooling_down => 1/2, 18 | heating_flags_limit => 1, 19 | cooling_submissions_limit => 1, 20 | dying_rounds => 120, 21 | dying_flag_price => 1 22 | } 23 | }, 24 | teams => [ 25 | { name => 'team1', 26 | network => '127.0.1.0/24', 27 | host => '127.0.1.3', 28 | logo => 'http://example.com', 29 | token => 'private', 30 | tags => ['edu', 'online', 'Russia'] 31 | }, 32 | { name => 'team2 (b)', 33 | network => '127.0.2.0/24', 34 | host => '127.0.2.3', 35 | tags => ['pro', 'offline', 'USA'] 36 | }, 37 | { name => 'team3 (b)', 38 | network => '127.0.3.0/24', 39 | host => '127.0.3.3', 40 | tags => ['pro', 'online', 'Germany'] 41 | } 42 | ], 43 | services => [ 44 | {name => 'down1', path => 't/checkers/down.pl', timeout => 0.5, tcp_port => 80}, 45 | {name => 'down2', path => 't/checkers/timeout.pl', timeout => 0.5, tcp_port => 81}, 46 | {name => 'up1', path => 't/checkers/up.pl', timeout => 0.5, tcp_port => 8080}, 47 | {name => 'up2', path => 't/checkers/up.pl', timeout => 0.5, tcp_port => 12345, active => ['2014-01-01 00:00:00', '2015-01-01 00:00:00']}, 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /cs.conf.example: -------------------------------------------------------------------------------- 1 | { hypnotoad => {listen => ['http://127.0.0.1:8080'], workers => 8}, 2 | postgres_uri => {'postgresql://cs:qwer@127.0.0.1:5432/cs'}, 3 | cs => { 4 | base_url => 'https://example.com:8080/', 5 | time => [['2017-01-18 17:00:00', '2015-04-18 17:15:00'], ['2015-04-18 17:20:00', '2020-04-18 17:25:00']], 6 | admin_auth => 'root:qwer', 7 | ctf_name => 'RuCTF 2015', 8 | round_length => 8, 9 | flag_life_time => 15, 10 | flags_secret => 'ohKai2eepi', 11 | checkers => { 12 | hostname => sub { my ($team, $service) = @_; "$service->{name}.$team->{host}" } 13 | }, 14 | scoring => { 15 | start_flag_price => 10, 16 | heating_speed => 1/12, 17 | max_flag_price => 30, 18 | cooling_down => 1/2, 19 | heating_flags_limit => 1, 20 | cooling_submissions_limit => 1, 21 | dying_rounds => 120, 22 | dying_flag_price => 1 23 | }, 24 | links => [{name => 'Visualization', ref => '/viz'}], 25 | static => ['/path/to/add/static'] 26 | }, 27 | teams => [ 28 | { name => 'team1', 29 | network => '127.0.1.0/24', 30 | host => '127.0.1.5' 31 | }, 32 | {name => 'team2', network => '127.0.2.0/24', host => '127.0.2.3'}, 33 | {name => 'team3', network => '127.0.3.0/24', host => '127.0.3.3'} 34 | ], 35 | services => [ 36 | {name => 'service1', path => '/home/and/tmp/cs/1.pl', timeout => 5, tcp_port => 80}, 37 | {name => 'service2', path => '/home/and/tmp/cs/2.pl', timeout => 3, tcp_port => 80}, 38 | {name => 'service3', path => '/bin/false', timeout => 5, tcp_port => 80}, 39 | {name => 'down', path => 't/checkers/down.pl', timeout => 1, tcp_port => 80}, 40 | {name => 'up', path => 't/checkers/up.pl', timeout => 1, tcp_port => 80}, 41 | {name => 'timeout', path => 't/checkers/timeout.pl', timeout => 1, tcp_port => 80} 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /lib/CS/Controller/Main.pm: -------------------------------------------------------------------------------- 1 | package CS::Controller::Main; 2 | use Mojo::Base 'Mojolicious::Controller'; 3 | 4 | sub index { $_[0]->render(%{$_[0]->model('scoreboard')->generate}) } 5 | 6 | sub team { 7 | my $c = shift; 8 | 9 | my $team = $c->pg->db->select('teams', undef, {id => $c->param('team_id')})->expand->hash; 10 | return $c->reply->not_found unless $team; 11 | 12 | $c->render(%{$c->model('scoreboard')->generate_for_team($team->{id})}, team => $team); 13 | } 14 | 15 | sub scoreboard { 16 | my $c = shift; 17 | $c->render(json => $c->model('scoreboard')->generate); 18 | } 19 | 20 | sub scoreboard_history { 21 | my $c = shift; 22 | $c->render(json => $c->model('scoreboard')->generate_history); 23 | } 24 | 25 | sub ctftime_scoreboard { 26 | my $c = shift; 27 | $c->render(json => $c->model('scoreboard')->generate_ctftime); 28 | } 29 | 30 | sub fb { 31 | my $c = shift; 32 | $c->render(json => $c->model('scoreboard')->generate_fb); 33 | } 34 | 35 | sub t { 36 | my $c = shift; 37 | 38 | my $team_ip = $c->req->headers->header('X-Real-IP') // '127.0.0.1'; 39 | my $team = $c->pg->db->query("select id, token from teams where ? <<= network", $team_ip)->hash; 40 | 41 | return $c->reply->not_found unless $team; 42 | 43 | $team->{token} =~ /^(\d+)_/; 44 | 45 | $c->render(json => {team_id => $1}); 46 | } 47 | 48 | sub update { 49 | my $c = shift->render_later; 50 | $c->tx->with_compression; 51 | $c->inactivity_timeout(300); 52 | 53 | my ($game_status) = $c->model('util')->game_status; 54 | return $c->finish if $game_status == -1; 55 | 56 | my $id = Mojo::IOLoop->recurring( 57 | 15 => sub { 58 | $c->stash(%{$c->model('scoreboard')->generate}); 59 | my $round = $c->stash('round'); 60 | my $scoreboard = $c->render_to_string('scoreboard')->to_string; 61 | 62 | $c->send({json => {round => "Round $round", scoreboard => $scoreboard}}); 63 | } 64 | ); 65 | 66 | $c->on(finish => sub { Mojo::IOLoop->remove($id) }); 67 | } 68 | 69 | 1; 70 | -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | For hosting a small CTF competition you can use a simple way to run the checksystem via `docker compose` on a single node. 2 | 3 | If you want to have a more scalable and performance deploy you can look at the [ansible](../ansible) scripts. 4 | 5 | _How to understand which node's configuration to use?_ 6 | 7 | It depends on the quality of the checkers and their dependencies. In our experience the good checkers use minimum amount of CPU and RAM and utilize only network I/O. 8 | 9 | We believe that the CTF competition for about **30 teams** and **10 services** can be deployed on single node with 32-48 dedicated modern CPU and 64 GB of RAM and 50 GB of SSD/NVMe local storage. Such configuration costs about 1.5$/hour in cloud providers. 10 | 11 | 12 | You need a node with a fresh GNU/Linux (for example Debian or Ubuntu) and a fresh version of [docker engine](https://docs.docker.com/engine/install/) and [docker compose](https://docs.docker.com/compose/install/). 13 | 14 | Copy the [docker-compose.yml](docker-compose.yml) and example of [Dockerfile](Dockerfile) files on your node and create files `cs.conf` (by looking at the [example](../cs.conf.example) in the repo and at the [CONFIGURE.md](../CONFIGURE.md)) and `.env` like this: 15 | 16 | ```bash 17 | POSTGRES_USER=postgres 18 | POSTGRES_PASSWORD=Secr3t 19 | POSTGRES_DB=cs 20 | POSTGRES_URI=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pg/${POSTGRES_DB} 21 | MOJO_LISTEN=http://0.0.0.0:8080 22 | ``` 23 | 24 | Don't forget about the checkers for the game. You must extend [Dockerfile](Dockerfile) with you CTF's specific checkers and their denendencies. Also don't forget to add execute permission (`chmod +x`) to [main files](../CONFIGURE.md#services) of the checkers. 25 | 26 | And then start the checksystem via `docker compose up -d` and use web interface at `http://localhost`. By default, this deployment uses a [modern scoreboard](https://github.com/HackerDom/ctf-scoreboard-client) and you can still use original scoreboard at `http://localhost/board`. 27 | -------------------------------------------------------------------------------- /lib/CS/Model/Flag.pm: -------------------------------------------------------------------------------- 1 | package CS::Model::Flag; 2 | use Mojo::Base 'MojoX::Model'; 3 | 4 | use Digest::SHA 'hmac_sha1_hex'; 5 | use String::Random 'random_regex'; 6 | use Time::HiRes 'time'; 7 | 8 | my $format = 'TEAM\d{3}_[A-Z0-9]{32}'; 9 | 10 | sub create { 11 | my ($self, $team_id) = @_; 12 | 13 | my $id = join('-', map random_regex('[a-z0-9]{4}'), 1 .. 3); 14 | 15 | my $data = sprintf('TEAM%03d_', $team_id) . random_regex('[A-Z0-9]{22}'); 16 | my $sign = uc substr hmac_sha1_hex($data, $self->app->config->{cs}{flags_secret}), 0, 10; 17 | 18 | return {id => $id, data => "${data}${sign}"}; 19 | } 20 | 21 | sub accept { 22 | my ($self, $team_id, $flag_data, $cb) = @_; 23 | my $app = $self->app; 24 | 25 | unless ($self->validate($flag_data)) { 26 | Mojo::IOLoop->next_tick(sub { 27 | $cb->({ok => 0, error => "[$flag_data] Denied: invalid or own flag"}); 28 | }); 29 | Mojo::IOLoop->one_tick unless Mojo::IOLoop->is_running; 30 | return; 31 | } 32 | 33 | $app->pg->db->query_p( 34 | 'select row_to_json(accept_flag(?, ?)) as r', $team_id, $flag_data 35 | )->then(sub { 36 | my $result = shift; 37 | 38 | my ($ok, $msg, $round, $victim_id, $service_id, $amount) = 39 | @{$result->expand->hash->{r}}{qw/f1 f2 f3 f4 f5 f6/}; 40 | 41 | unless ($ok) { 42 | return $cb->({ok => 0, error => "[$flag_data] $msg"}); 43 | } 44 | 45 | my $data = {round => $round, service_id => $service_id, team_id => $team_id, victim_id => $victim_id}; 46 | $app->pg->pubsub->json('flag')->notify(flag => $data); 47 | 48 | $msg = "[$flag_data] Accepted. $amount flag points"; 49 | $cb->({ok => 1, message => $msg}); 50 | })->catch(sub { 51 | $app->log->error("[flags] Error while accept: $_[0]"); 52 | return $cb->({ok => 0, error => 'Please try again later'}); 53 | })->wait; 54 | } 55 | 56 | sub validate { 57 | my ($self, $flag) = @_; 58 | 59 | return undef unless $flag =~ /^$format$/; 60 | 61 | my $data = substr $flag, 0, -10; 62 | my $sign = uc substr hmac_sha1_hex($data, $self->app->config->{cs}{flags_secret}), 0, 10; 63 | return $sign eq substr $flag, -10; 64 | } 65 | 66 | 1; 67 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | REGISTRY: ghcr.io 7 | IMAGE_NAME: ${{ github.repository }} 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | container: 13 | image: perl:5.40 14 | services: 15 | postgres: 16 | image: postgres 17 | env: 18 | POSTGRES_PASSWORD: postgres 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: install psql 24 | run: apt-get update && apt-get install -y --no-install-recommends postgresql-client 25 | 26 | - name: create db 27 | env: 28 | PGPASSWORD: postgres 29 | run: psql -h postgres -U postgres -c 'create database cs_test;' 30 | 31 | - name: install dependencies 32 | run: cpanm -n --installdeps . 33 | 34 | - name: perl version 35 | run: perl -v 36 | 37 | - name: postgresq version 38 | env: 39 | PGPASSWORD: postgres 40 | run: psql -h postgres -U postgres -c 'select version();' 41 | 42 | - name: run tests 43 | env: 44 | POSTGRES_URI: postgresql://postgres:postgres@postgres:5432/cs_test 45 | run: prove -lv t 46 | 47 | build_and_push: 48 | needs: test 49 | runs-on: ubuntu-latest 50 | permissions: 51 | contents: read 52 | packages: write 53 | steps: 54 | - name: checkout 55 | uses: actions/checkout@v3 56 | 57 | - name: login to registry 58 | uses: docker/login-action@v2 59 | with: 60 | registry: ${{ env.REGISTRY }} 61 | username: root 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | - name: extract metadata 65 | id: meta 66 | uses: docker/metadata-action@v4 67 | with: 68 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 69 | 70 | - name: build and push image to registry 71 | uses: docker/build-push-action@v3 72 | with: 73 | file: docker/Dockerfile 74 | context: . 75 | cache-from: type=gha 76 | pull: true 77 | push: true 78 | tags: ${{ steps.meta.outputs.tags }} 79 | -------------------------------------------------------------------------------- /templates/main/team.html.ep: -------------------------------------------------------------------------------- 1 | % layout 'default'; 2 | % title app->ctf_name; 3 | 4 | % content_for r => begin 5 | Round <%= $round %> 6 | % end 7 | 8 |
9 |

<%= $team->{name} %>

10 | 11 | 12 | 13 | % for my $status (@{app->model('checker')->statuses}) { 14 | 15 | % } 16 | 17 |
<%= uc $status->[0] %>
18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | % for my $sid (sort { $a <=> $b } keys %{app->services}) { 27 | 28 | % } 29 | 30 | 31 | 32 | % for my $team (@$scoreboard) { 33 | 34 | 35 | 36 | 37 | % for my $service (@{$team->{services}}) { 38 | 51 | % } 52 | 53 | % } 54 | 55 |
#roundscore<%= app->services->{$sid}{name} %>
<%= $team->{n} %><%= $team->{round} %><%= $team->{score} %> 42 |
SLA
<%= $service->{sla} %>%
43 |
FP
<%= $service->{fp} %>
44 |
45 | <%= $service->{flags} %> 46 | % if (my $sflags = $service->{sflags}) { 47 | / -<%= $service->{sflags} %> 48 | % } 49 |
50 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /lib/CS/Controller/Api.pm: -------------------------------------------------------------------------------- 1 | package CS::Controller::Api; 2 | use Mojo::Base 'Mojolicious::Controller'; 3 | 4 | use Sereal::Dclone 'dclone'; 5 | 6 | sub info { 7 | my $c = shift; 8 | 9 | my $info = { 10 | contestName => $c->app->ctf_name, 11 | roundsCount => int($c->model('util')->game_duration) 12 | }; 13 | 14 | my $teams = $c->pg->db->select('teams', [qw/id name network host details/])->expand->hashes; 15 | for my $team (@$teams) { 16 | $info->{teams}{$team->{id}} = {%{$team->{details}}, %$team}; 17 | } 18 | $info->{services}{$_->{id}} = $_->{name} for values %{$c->app->services}; 19 | 20 | my $time = $c->model('util')->game_time; 21 | 22 | $c->render(json => {%$info, %$time}); 23 | } 24 | 25 | sub events { 26 | my $c = shift; 27 | $c->tx->with_compression; 28 | $c->inactivity_timeout(300); 29 | my $pubsub = $c->pg->pubsub; 30 | 31 | my $cb1 = $pubsub->json('scoreboard')->listen( 32 | scoreboard => sub { 33 | my $value = $c->model('scoreboard')->generate; 34 | $c->send({json => {type => 'state', value => $value}}); 35 | } 36 | ); 37 | 38 | my $cb2 = $pubsub->json('flag')->listen( 39 | flag => sub { 40 | my $data = dclone(pop); 41 | $data->{attacker_id} = delete $data->{team_id}; 42 | $c->send({json => {type => 'attack', value => $data}}); 43 | } 44 | ); 45 | 46 | my $cb3 = $pubsub->listen( 47 | message => sub { 48 | my $msg = pop; 49 | $c->send({json => {type => 'message', value => $msg}}); 50 | } 51 | ); 52 | 53 | my $cb4 = $pubsub->listen(reload => sub { $c->send({json => {type => 'reload'}}) }); 54 | 55 | $c->on(finish => sub { $pubsub->unlisten(scoreboard => $cb1)->unlisten(flag => $cb2)->unlisten(message => $cb3)->unlisten(reload => $cb4) }); 56 | 57 | my $data = $c->model('scoreboard')->generate; 58 | $c->send({json => {type => 'state', value => $data}}); 59 | } 60 | 61 | sub teams { 62 | my $c = shift; 63 | 64 | my $teams = $c->pg->db->query('select id, name, network from teams')->hashes->reduce(sub { 65 | $a->{$b->{id}} = { 66 | id => $b->{id}, 67 | name => $b->{name}, 68 | network => $b->{network} 69 | }; 70 | $a 71 | }, {}); 72 | 73 | $c->render(json => $teams); 74 | } 75 | 76 | sub services { 77 | my $c = shift; 78 | 79 | $c->render(json => $c->model('util')->get_active_services); 80 | } 81 | 82 | 1; 83 | -------------------------------------------------------------------------------- /deploy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | x-cs: &cs-common 4 | build: . 5 | image: checksystem:latest 6 | cpu_shares: 1024 7 | init: true 8 | restart: unless-stopped 9 | volumes: 10 | - "./cs.conf:/app/cs.conf" 11 | environment: 12 | - POSTGRES_USER 13 | - POSTGRES_PASSWORD 14 | - POSTGRES_DB 15 | - POSTGRES_URI 16 | - MOJO_CONFIG=/app/cs.conf 17 | 18 | services: 19 | cs-manager: 20 | <<: *cs-common 21 | depends_on: 22 | - init 23 | command: 24 | - /bin/bash 25 | - -xc 26 | - >- 27 | while true; do 28 | perl script/cs check_db 29 | if [[ $$? == 0 ]]; then break; fi 30 | sleep 2 31 | done && 32 | perl script/cs manager 33 | 34 | cs-web: 35 | <<: *cs-common 36 | depends_on: 37 | - init 38 | ports: 39 | - "80:8080" 40 | command: 41 | - /bin/bash 42 | - -xc 43 | - >- 44 | while true; do 45 | perl script/cs check_db 46 | if [[ $$? == 0 ]]; then break; fi 47 | sleep 2 48 | done && 49 | hypnotoad -f script/cs 50 | 51 | cs-workers: 52 | <<: *cs-common 53 | depends_on: 54 | - init 55 | scale: 4 56 | command: 57 | - /bin/bash 58 | - -xc 59 | - >- 60 | while true; do 61 | perl script/cs check_db 62 | if [[ $$? == 0 ]]; then break; fi 63 | sleep 2 64 | done && 65 | perl script/cs minion worker -q default -q checker -j 64 66 | 67 | init: 68 | <<: *cs-common 69 | restart: "no" 70 | depends_on: 71 | - pg 72 | command: 73 | - /bin/bash 74 | - -xc 75 | - >- 76 | while true; do 77 | perl script/cs check_db 78 | if [[ $$? == 0 ]]; then break; fi 79 | 80 | perl script/cs init_db 81 | if [[ $$? == 0 ]]; then break; fi 82 | 83 | sleep 2 84 | done 85 | 86 | pg: 87 | image: postgres 88 | cpu_shares: 1024 89 | volumes: 90 | - "cs_pg_data:/var/lib/postgresql/data" 91 | environment: 92 | - POSTGRES_USER 93 | - POSTGRES_PASSWORD 94 | - POSTGRES_DB 95 | command: 96 | - -c 97 | - shared_buffers=4GB 98 | - -c 99 | - work_mem=8MB 100 | - -c 101 | - max_connections=1024 102 | 103 | volumes: 104 | cs_pg_data: 105 | -------------------------------------------------------------------------------- /lib/CS/Command/init_db.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::init_db; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | has description => 'Init db schema'; 5 | 6 | sub run { 7 | my $app = shift->app; 8 | my $db = $app->pg->db; 9 | 10 | # Teams 11 | for my $team (@{$app->config->{teams}}) { 12 | my $values = { 13 | name => delete $team->{name}, 14 | network => delete $team->{network}, 15 | host => delete $team->{host}, 16 | token => delete $team->{token} 17 | }; 18 | $values->{id} = delete $team->{id} if defined $team->{id}; 19 | my $details = {details => {-json => $team}}; 20 | $db->insert(teams => {%$values, %$details}); 21 | } 22 | 23 | # Services 24 | for my $service (@{$app->config->{services}}) { 25 | my $service_info = $app->model('checker')->info($service); 26 | 27 | my $service_data = { 28 | name => $service->{name}, 29 | timeout => $service->{timeout}, 30 | path => $service->{path}, 31 | vulns => $service_info->{vulns}{distribution}, 32 | public_flag_description => $service_info->{public_flag_description} 33 | }; 34 | $service_data->{id} = $service->{id} if defined $service->{id}; 35 | if (my $active = $service->{active}) { 36 | $service_data->{ts_start} = $active->[0]; 37 | $service_data->{ts_end} = $active->[1]; 38 | } 39 | my $service_id = $db->insert(services => $service_data, {returning => 'id'})->hash->{id}; 40 | 41 | $db->insert(vulns => {service_id => $service_id, n => $_}) for 1 .. $service_info->{vulns}{count}; 42 | } 43 | 44 | # Scores 45 | $db->insert(rounds => {n => 0}); 46 | $db->query(q{ 47 | insert into service_activity (round, service_id, active, phase) 48 | select 0, id, false, 'NOT_RELEASED' from services 49 | }); 50 | $db->query(' 51 | insert into flag_points (round, team_id, service_id, amount) 52 | select 0, teams.id, services.id, 0 from teams cross join services 53 | '); 54 | my $teams = $db->select('teams', ['id', 'details'])->expand->hashes; 55 | for my $team (@$teams) { 56 | for my $service_id (keys %{$team->{details}{initial_flag_points} // {}}) { 57 | $db->query(' 58 | update flag_points set amount = ? 59 | where round = 0 and team_id = ? and service_id = ? 60 | ', $team->{details}{initial_flag_points}{$service_id}, $team->{id}, $service_id); 61 | } 62 | } 63 | $db->query(' 64 | insert into sla (round, team_id, service_id, successed, failed) 65 | select 0, teams.id, services.id, 0, 0 from teams cross join services 66 | '); 67 | $app->model('score')->scoreboard($db, 0); 68 | } 69 | 70 | 1; 71 | -------------------------------------------------------------------------------- /CONFIGURE.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | 3 | Config file for the checksystem is a just [Perl file](https://docs.mojolicious.org/Mojolicious/Plugin/Config) which returning a hash object. You can found example at [cs.conf.example](cs.conf.example). 4 | 5 | # Available options: 6 | 7 | - `hypnotoad`: this hash describe the settings about hypnotoad web server. You can read details in the [official documentation](https://docs.mojolicious.org/Mojo/Server/Hypnotoad). 8 | - `postgres_uri`: [connection string](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) for postgres database. You can override this option by `POSTGRES_URI` environmental variable. 9 | - `base_url`: base `url` which used to create a right redirect urls. Useful if you publish scoreboard in the Internet. 10 | - `cs.time`: list of the game's periods. You must specify the time in the same time zone as the server that runs the checksystem. If you use [docker deploy](deploy/README.md) then specify time in `UTC` time zone. If you don't specify `cs.time` option, then the game will be endless, which can be convenient for trainings. 11 | - `cs.round_length`: length of round in seconds. Default value is `60` 12 | - `cs.flag_life_time`: time of flag's life in rounds. Doesn't persist after any breaks in the game. Default value is `15`. 13 | - `cs.flags_secret`: secret key for HMAC in flag's data. 14 | - `cs.checkers.hostname`: an optional callback function to detect an address of vuln's service which passed to the checkers. 15 | - `cs.ctf_name`: a name of the CTF, displayed in the scoreboard. Default vaule is `CTF`. 16 | - `cs.admin_auth`: a basic auth creadentials of admin page (which can be accessed by `/admin` route). 17 | - `cs.scoring`: this hash object describe the settings about the [scoring](https://docs.google.com/document/d/1uU9f38UpxdsMeuAsM5TAnp_i4T-DhM-Ur9JOxUeTc8M/preview#heading=h.xdi2syovqugn). 18 | - `teams`: a list of the [teams](#teams). 19 | - `services`: a list of the [services](#services). 20 | 21 | ## teams 22 | 23 | - `name`: 24 | - `network`: 25 | - `host`: 26 | - `logo`: 27 | - `token`: 28 | - `tags`: 29 | 30 | ## services 31 | 32 | Available attributes of the service's hash: 33 | 34 | - `name`: a name of the service. 35 | - `path`: a path (absolute or relative to the root catalog of this project) to the service's main executable file. 36 | - `timeout`: an amount in seconds of checker's timeout. The service will have the status `down` in the current round if there is a timeout. An actual timeout for current stage (`check`, `put`, `get`) in the round is calculated as miniumum of the `timeout` from config and time prior to the start of the next round. 37 | - `tcp_port`: a main tcp port of the service. 38 | -------------------------------------------------------------------------------- /ansible/roles/web/templates/cs.nginx.conf.j2: -------------------------------------------------------------------------------- 1 | proxy_cache_path /var/cache/nginx/cs keys_zone=cs:10m max_size=512m inactive=300s; 2 | proxy_cache_path /var/cache/nginx/cs_flag_ids keys_zone=cs_flag_ids:10m max_size=512m inactive=300s; 3 | 4 | log_format cs '$remote_addr - $remote_user [$time_local] "$request" ' 5 | '$status $body_bytes_sent "$http_referer" ' 6 | '"$http_user_agent" "$http_x_forwarded_for" ' 7 | '$request_time "$upstream_addr" $upstream_response_time'; 8 | 9 | upstream cs { 10 | server {{ cs_hypnotoad_listen }} max_fails=5 fail_timeout=5s; 11 | keepalive {{ cs_nginx_upstream_keepalive }}; 12 | } 13 | 14 | upstream cs_flags { 15 | {% for server in cs_hypnotoad_flags_listen -%} 16 | server {{ server }} max_fails=5 fail_timeout=5s; 17 | {% endfor -%} 18 | keepalive {{ cs_nginx_upstream_keepalive }}; 19 | } 20 | 21 | map $http_upgrade $connection_upgrade { 22 | '' close; 23 | default upgrade; 24 | } 25 | 26 | map $request_uri $do_not_cache { 27 | "~*scoreboard.json" "0"; 28 | default "1"; 29 | } 30 | 31 | server { 32 | listen {{ cs_nginx_listen }}; 33 | 34 | {% if 'https' == cs_base_url|urlsplit('scheme') -%} 35 | listen 443 ssl; 36 | ssl_certificate /etc/letsencrypt/live/{{ cs_base_url|urlsplit('hostname') }}/fullchain.pem; 37 | ssl_certificate_key /etc/letsencrypt/live/{{ cs_base_url|urlsplit('hostname') }}/privkey.pem; 38 | {% endif %} 39 | 40 | access_log /var/log/nginx/cs.access.log cs; 41 | 42 | gzip on; 43 | gzip_types text/plain text/css application/javascript application/json; 44 | 45 | client_max_body_size 24m; 46 | client_body_buffer_size 8m; 47 | 48 | proxy_http_version 1.1; 49 | 50 | proxy_max_temp_file_size 0; 51 | 52 | proxy_connect_timeout 5s; 53 | 54 | proxy_set_header X-Real-IP $remote_addr; 55 | proxy_set_header Host $http_host; 56 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 57 | 58 | proxy_set_header Upgrade $http_upgrade; 59 | proxy_set_header Connection $connection_upgrade; 60 | 61 | location / { 62 | proxy_pass http://cs; 63 | 64 | proxy_cache cs; 65 | proxy_no_cache $do_not_cache; 66 | proxy_cache_valid 15s; 67 | } 68 | 69 | location /flag_ids { 70 | proxy_pass http://cs; 71 | 72 | proxy_cache cs_flag_ids; 73 | proxy_cache_valid 15s; 74 | proxy_cache_key "$uri$is_args$args$http_x_team_token"; 75 | } 76 | 77 | location /flags { 78 | proxy_pass http://cs_flags; 79 | proxy_next_upstream non_idempotent error timeout; 80 | proxy_next_upstream_timeout 20ms; 81 | } 82 | 83 | location /data { 84 | alias /var/www; 85 | } 86 | 87 | location = /basic_status { 88 | stub_status; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /public/css/default.css: -------------------------------------------------------------------------------- 1 | /* Statuses */ 2 | .status_up { 3 | background-color: #6fd6a6; 4 | } 5 | 6 | .status_corrupt { 7 | background-color: #ffef65; 8 | } 9 | 10 | .status_mumble { 11 | background-color: #f9a963; 12 | } 13 | 14 | .status_down { 15 | background-color: #e97c7c; 16 | } 17 | 18 | /* Statuses descriptions */ 19 | .status_descriptions tbody tr td { 20 | padding: 10px 0px; 21 | color: #500A0A; 22 | } 23 | 24 | #scoreboard_wrapper { 25 | text-align: center; 26 | } 27 | 28 | /* Scoreboard */ 29 | #scoreboard .table-bordered > tbody > tr > th, 30 | #scoreboard .table-bordered > thead > tr > th, 31 | #scoreboard .table-bordered > tbody > tr > td, 32 | #scoreboard .table-bordered > thead > tr > td { 33 | border-right-width: 0px; 34 | border-left-width: 0px; 35 | border-bottom: 1px solid #ddd; 36 | } 37 | 38 | /* Gold, silve and bronze places */ 39 | #scoreboard table.scoreboard tr.team:nth-child(1) td:not(.team_service) { 40 | background-color: #FFE8A7; 41 | } 42 | 43 | #scoreboard table.scoreboard tr.team:nth-child(2) td:not(.team_service) { 44 | background-color: #D8D8D8; 45 | } 46 | 47 | #scoreboard table.scoreboard tr.team:nth-child(3) td:not(.team_service) { 48 | background-color: #E3B689; 49 | } 50 | 51 | #scoreboard table.scoreboard tr.team:nth-child(3) td:not(.team_service) .team_server { 52 | color: white; 53 | } 54 | 55 | /* Scoreboard's team and service */ 56 | .team .score, 57 | .team .team_info, 58 | .team .team_logo, 59 | .team .place { 60 | vertical-align: middle; 61 | } 62 | 63 | .team .team_logo { 64 | padding: 0px; 65 | text-align: center; 66 | } 67 | 68 | #scoreboard table tr.team:nth-child(-n+3) .team_logo img { 69 | box-shadow: 0px 0px 5px 0px rgba(212, 212, 212, 0.75); 70 | } 71 | 72 | .team .team_info { 73 | padding-left: 10px; 74 | } 75 | 76 | .team .place, 77 | #scoreboard th.place { 78 | text-align: center; 79 | } 80 | 81 | #scoreboard th.service_name { 82 | padding-left: 10px; 83 | white-space: nowrap; 84 | } 85 | 86 | .team .team_info .team_name { 87 | color: #222; 88 | font-weight: bold; 89 | max-width: 250px; 90 | } 91 | 92 | .team_name a { 93 | color: #222; 94 | text-decoration: underline; 95 | } 96 | 97 | .team .team_info .team_server { 98 | color: #999; 99 | max-width: 250px; 100 | } 101 | 102 | .team .team_service { 103 | text-align: left; 104 | font-size: 9pt; 105 | padding: 10px 10px !important; 106 | } 107 | 108 | .team .team_service > div { 109 | display: table-row; 110 | } 111 | 112 | .team .team_service .param_name, 113 | .team .team_service .param_value { 114 | display: table-cell; 115 | } 116 | 117 | .team .team_service .param_name { 118 | font-weight: bold; 119 | } 120 | 121 | .team .team_service .param_value { 122 | padding-left: 3px; 123 | } 124 | 125 | #scoreboard table tr:last-child td { 126 | border-bottom: 1px solid #ddd; 127 | } 128 | -------------------------------------------------------------------------------- /lib/CS/Controller/Flags.pm: -------------------------------------------------------------------------------- 1 | package CS::Controller::Flags; 2 | use Mojo::Base 'Mojolicious::Controller'; 3 | 4 | sub put { 5 | my $c = shift->render_later; 6 | 7 | my ($game_status) = $c->app->model('util')->game_status; 8 | unless ($game_status == 1) { 9 | return $c->render(json => {status => \0, msg => 'Game is not active for now'}, status => 400); 10 | } 11 | 12 | if ($c->req->body_size > 16 * 1024) { 13 | return $c->render(json => {status => \0, msg => 'Message is too big'}, status => 400); 14 | } 15 | 16 | my $token = $c->req->headers->header('X-Team-Token') // ''; 17 | return $c->render(json => {status => \0, msg => "Invalid token '$token'"}, status => 400) 18 | unless my $team = $c->pg->db->select('teams', ['id'], {token => $token})->hash; 19 | 20 | my $flags = $c->req->json; 21 | return $c->render(json => {status => \0, msg => 'Invalid format'}, status => 400) 22 | unless ref $flags eq 'ARRAY'; 23 | 24 | my $results = []; 25 | 26 | my $do; 27 | $do = sub { 28 | my $flag = shift @$flags; 29 | 30 | unless ($flag) { 31 | undef $do; 32 | $c->render(json => $results); 33 | return; 34 | } 35 | 36 | $c->model('flag')->accept( 37 | $team->{id}, $flag, 38 | sub { 39 | my $msg = $_[0]->{ok} ? $_[0]->{message} : $_[0]->{error}; 40 | push @$results, {flag => $flag, status => \$_[0]->{ok}, msg => $msg}; 41 | $do->(); 42 | } 43 | ); 44 | }; 45 | 46 | $do->(); 47 | } 48 | 49 | sub list { 50 | my $c = shift; 51 | 52 | my $token = $c->req->headers->header('X-Team-Token') // ''; 53 | return $c->render(json => {status => \0, msg => 'Invalid token'}, status => 400) 54 | unless my $team = $c->pg->db->select('teams', ['id'], {token => $token})->hash; 55 | 56 | return $c->render(json => {status => \0, msg => 'Invalid service_id'}, status => 400) 57 | unless my $service = $c->app->services->{$c->param('service_id') // $c->param('service')}; 58 | 59 | my $flags = $c->pg->db->query(q{ 60 | select 61 | t.id, 62 | json_build_object( 63 | 'id', t.id, 64 | 'name', name, 65 | 'network', network, 66 | 'host', host, 67 | 'details', details 68 | ) as team, 69 | array_agg(public_id) filter (where public_id is not null) as flag_ids 70 | from ( 71 | select * from flags 72 | where 73 | service_id = ? and team_id != ? and 74 | public_id is not null and not expired 75 | ) as f 76 | right join teams as t on t.id = f.team_id 77 | group by t.id 78 | }, $service->{id}, $team->{id}); 79 | my $flag_ids = $flags->expand->hashes->reduce(sub { 80 | $a->{$b->{id}}{host} = $c->model('util')->get_service_host($b->{team}, $service); 81 | $a->{$b->{id}}{flag_ids} = $b->{flag_ids} // []; 82 | $a; 83 | }, {}); 84 | 85 | $c->render(json => { 86 | flag_id_description => $service->{public_flag_description}, 87 | flag_ids => $flag_ids 88 | }); 89 | } 90 | 91 | 1; 92 | -------------------------------------------------------------------------------- /templates/scoreboard.html.ep: -------------------------------------------------------------------------------- 1 | % my $checker = app->model('checker'); 2 |
3 | 4 | 5 | % for my $status (@{$checker->statuses}) { 6 | 7 | % } 8 | 9 |
<%== uc $status->[0] %>
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | % for my $sid (sort { $a <=> $b } keys %{app->services}) { 18 | 19 | % } 20 | 21 | 22 | 23 | % for my $team (@$scoreboard) { 24 | 25 | % my $suffix = $team->{d} ? ($team->{d} > 0 ? "(+$team->{d})" : "($team->{d})") : ''; 26 | 27 | 28 | 34 | 35 | % for my $service (@{$team->{services}}) { 36 | 57 | % } 58 | 59 | % } 60 | 61 |
#teamscore<%== app->services->{$sid}{name} %>
<%== $team->{n} %><%== $suffix %> 29 | 32 |
<%== $team->{host} %>
33 |
<%== $team->{score} %> 40 | % if (current_route eq 'admin_index') { 41 |
42 | 44 | 45 | 46 |
47 | % } 48 |
SLA
<%== $service->{sla} %>%
49 |
FP
<%== $service->{fp} %>
50 |
51 | <%== $service->{flags} %> 52 | % if (my $sflags = $service->{sflags}) { 53 | / -<%== $service->{sflags} %> 54 | % } 55 |
56 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Checksystem 2 | 3 | It's a scalable competition platform for attack-defense [CTF](https://en.wikipedia.org/wiki/Capture_the_flag_(cybersecurity)). 4 | 5 | It's written in [Perl](https://www.perl.org/) with [Mojolicious](https://www.mojolicious.org/) framework. 6 | 7 | ## Architecture 8 | 9 | - `cs-manager`: a component that is responsible for the beginning and ending of the game, for managing breaks, for generating rounds. After the start of the round it puts tasks in the queue for launching checkers and also puts the task in the queue for calculating the scoreboard for the past rounds. 10 | 11 | - `cs-watcher`: an optional component which periodically (several times per round) checks the availability of the TCP port for each service and team and skips launching the checkers if the port is not available. 12 | 13 | - `postgres`: the [database](https://www.postgresql.org/) which used to store all the data required for the checksystem. It's also used as a backend for the [Minion](https://docs.mojolicious.org/Minion) job queue. 14 | 15 | - `cs-worker`: the minion workers which runs the checkers for teams and services. 16 | 17 | - `cs-web`: the [hypnotoad](https://docs.mojolicious.org/hypnotoad) which serves checksystem's web components: scoreboars, API, flags. 18 | 19 | ## Useful URLs 20 | 21 | - `/board`: a built-in scoreboard with simple html table with teams, services and scores. 22 | - `/admin`: an admin page for the game which allows to view checker's logs. 23 | - `/admin/info`: an admin page with useful game statistics. 24 | - `/admin/minion`: an admin page with jobs statistics. 25 | - `/ctftime/scoreboard.json`: a scoreboard in [CTFtime](https://ctftime.org/) format. 26 | 27 | ## HTTP API 28 | 29 | ### For scoreboard 30 | 31 | ### For teams 32 | 33 | ## API between checkers and checksystem 34 | 35 | Checker is an executable file that get input from the checksystem via args and `STDIN` and return output via exit code and `STDOUT/STDERR`. 36 | 37 | Checksystem runs checkers with that format: `/path/to/checker mode host id flag` 38 | 39 | ### Modes 40 | 41 | #### INFO 42 | 43 | Cheker must return `101` exit code and print to `STDOUT` lines with `key: value` format. Supported keys: 44 | 45 | - `public_flag_description`: description of flag in the service for teams. 46 | - `vulns`: number of vulns in the service, must be set to `1`. 47 | 48 | ##### CHECK 49 | 50 | The checker must check the general functionality of the service at `host`. 51 | 52 | ##### PUT 53 | 54 | The checker must put the `flag` to the service at `host` by `id`. If exit code is 101 then checker should print to `STDOUT` a `JSON object` with `public_flag_id` field wich will be accessible to the teams. You can add any additional fields to the object. This `JSON object` will be passed to the `GET` mode in the future. 55 | 56 | ##### GET 57 | 58 | The checker must try to get `flag` from the service at `host` by `id`. 59 | ### Exit codes 60 | 61 | - `101` OK. 62 | - `102` CORRUPT: The service works fine, but there is no requested flag (only in `get` mode) 63 | - `103` MUMBLE: The service works incorrect 64 | - `104` DOWN: The service doesn't work. 65 | - `110` CHECKER ERROR: Internal error of checker. 66 | -------------------------------------------------------------------------------- /templates/admin/view.html.ep: -------------------------------------------------------------------------------- 1 | % layout 'default'; 2 | % title '[admin] ' . app->ctf_name; 3 | 4 | % my $view_line = begin 5 | % my ($name, $data) = @_; 6 | % if ($data) { 7 |
<%= $name %>
<%= $data %>
8 | % } 9 | % end 10 | 11 | % my $paging = begin 12 | 22 | % end 23 | 24 |
25 |

<%= app->services->{param('service_id')}{name} // '*' %> on <%= $team_name %>

26 | 27 |
28 |
29 | 30 |

Status

31 |
32 |
33 | 34 | 39 |
40 | 41 |
42 | 43 | %= $paging->(); 44 | 45 | % my $checker = app->model('checker'); 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | % for my $result (@$view) { 54 | 55 | 56 | 57 | 76 | 77 | % } 78 |
roundstatusresult
<%= $result->{round} %><%= $result->{status} %> 58 | %= $view_line->('Error', $result->{result}{error}); 59 | % for my $state (qw/check put get_1 get_2/) { 60 | % my $r = $result->{result}{$state}; 61 | % next unless $r->{command}; 62 | % my $status = $checker->status2name->{$result->{status} // ''} // ''; 63 | <%= $state %> [vuln_<%= $result->{result}{vuln}{n} %>] (<%= $result->{result}{$state}{ts} %>) 64 |
65 | %= $view_line->('Command', $r->{command}); 66 | %= $view_line->('Elapsed', $r->{elapsed}); 67 | %= $view_line->('Exit code', "$r->{exit_code} ($status)"); 68 | %= $view_line->('Exit', join ', ', map { "$_ => $r->{exit}{$_}" } sort keys %{$r->{exit}}); 69 | %= $view_line->('Timeout', $r->{timeout}); 70 | %= $view_line->('STDOUT', $r->{stdout}); 71 | %= $view_line->('STDERR', $r->{stderr}); 72 | %= $view_line->('Exception', $r->{exception}); 73 |
74 | % } 75 |
79 | 80 | %= $paging->(); 81 |
82 | -------------------------------------------------------------------------------- /ansible/cs-start.yml: -------------------------------------------------------------------------------- 1 | - hosts: master 2 | tasks: 3 | - name: start web 4 | docker_container: 5 | name: cs_web 6 | image: "{{ cs_docker_image }}" 7 | command: "hypnotoad -f script/cs" 8 | init: true 9 | container_default_behavior: no_defaults 10 | restart_policy: unless-stopped 11 | detach: yes 12 | published_ports: 13 | - "{{ private_ip }}:{{ cs_hypnotoad_listen.split(':') | last }}:{{ cs_hypnotoad_listen.split(':') | last }}" 14 | env: 15 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 16 | 17 | - name: start manager 18 | docker_container: 19 | name: cs_manager 20 | image: "{{ cs_docker_image }}" 21 | command: "perl script/cs manager" 22 | init: true 23 | container_default_behavior: no_defaults 24 | restart_policy: unless-stopped 25 | detach: yes 26 | env: 27 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 28 | 29 | - name: start default worker 30 | docker_container: 31 | name: cs_default_worker 32 | image: "{{ cs_docker_image }}" 33 | command: "perl script/cs minion worker -q default -j {{ cs_worker_default_jobs }}" 34 | init: true 35 | container_default_behavior: no_defaults 36 | restart_policy: unless-stopped 37 | detach: yes 38 | env: 39 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 40 | 41 | - name: start watcher 42 | docker_container: 43 | name: cs_watcher 44 | image: "{{ cs_docker_image }}" 45 | command: "perl script/cs watcher" 46 | init: true 47 | container_default_behavior: no_defaults 48 | restart_policy: unless-stopped 49 | detach: yes 50 | ulimits: 51 | - "nofile:{{ cs_limit_nofile }}" 52 | env: 53 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 54 | 55 | - hosts: flags 56 | tasks: 57 | - name: start web flags 58 | docker_container: 59 | name: cs_web_flags 60 | image: "{{ cs_docker_image }}" 61 | command: "hypnotoad -f script/cs" 62 | init: true 63 | container_default_behavior: no_defaults 64 | restart_policy: unless-stopped 65 | detach: yes 66 | published_ports: 67 | - "{{ private_ip }}:{{ cs_hypnotoad_flags_port }}:{{ cs_hypnotoad_flags_port }}" 68 | env: 69 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 70 | 71 | - hosts: checkers 72 | tasks: 73 | - name: start checkers 74 | docker_container: 75 | name: "cs_checker_worker_{{ item }}" 76 | image: "{{ cs_docker_image }}" 77 | command: "perl script/cs minion worker {{ cs_worker_checkers_queues }} -j {{ cs_worker_checkers_jobs }}" 78 | init: true 79 | container_default_behavior: no_defaults 80 | restart_policy: unless-stopped 81 | detach: yes 82 | env: 83 | POSTGRES_URI: "postgresql://{{ pg_cs_user }}:{{ pg_cs_pass }}@{{ pg_cs_host }}/{{ pg_cs_db }}" 84 | with_sequence: count={{ cs_worker_instance }} 85 | -------------------------------------------------------------------------------- /lib/CS/Command/manager.pm: -------------------------------------------------------------------------------- 1 | package CS::Command::manager; 2 | use Mojo::Base 'Mojolicious::Command'; 3 | 4 | use List::Util 'max'; 5 | use Mojo::Collection 'c'; 6 | 7 | has description => 'Run CTF game'; 8 | 9 | has round => sub { $_[0]->app->pg->db->select(rounds => 'max(n)')->array->[0] }; 10 | 11 | sub run { 12 | my $self = shift; 13 | my $app = $self->app; 14 | 15 | my $start = $app->model('util')->game_time->{start} // 0; 16 | 17 | my $sleep; 18 | if (time < $start) { $sleep = $start - time } 19 | else { 20 | my $round_start = 21 | $app->round_length + $app->pg->db->select(rounds => 'extract(epoch from max(ts))')->array->[0]; 22 | $sleep = time > $round_start ? 0 : $round_start - time; 23 | } 24 | 25 | Mojo::IOLoop->timer( 26 | $sleep => sub { 27 | Mojo::IOLoop->recurring($app->round_length => sub { $self->start_round }); 28 | $self->start_round; 29 | } 30 | ); 31 | Mojo::IOLoop->start; 32 | } 33 | 34 | sub start_round { 35 | my $self = shift; 36 | my $app = $self->app; 37 | 38 | # Check end of game 39 | my ($game_status, $init_round) = $app->model('util')->game_status; 40 | $app->minion->enqueue(scoreboard => [$self->round]) if $game_status == -1; 41 | return unless $game_status == 1; 42 | 43 | my $db = $app->pg->db; 44 | my $round = $db->insert('rounds', {n => \'(select max(n)+1 from rounds)'}, {returning => 'n'})->hash->{n}; 45 | $self->round($round); 46 | $app->minion->enqueue(scoreboard => [] => {delay => 10}); 47 | $app->log->debug("Start new round #$round"); 48 | 49 | my $status = $self->get_monitor_status; 50 | my $active_services = $app->model('util')->update_service_phases($round); 51 | 52 | my $check_round = $round - $app->flag_life_time; 53 | if ($init_round && $init_round > 1) { 54 | $check_round = max($check_round, $init_round - 1); 55 | } 56 | $db->query(q{ 57 | update flags set expired = true 58 | where expired = false and round <= ? 59 | }, $check_round); 60 | 61 | my $alive_flags = $db->query(q{ 62 | select team_id, vuln_id, json_agg(json_build_object('id', id, 'data', data)) as flags 63 | from flags 64 | where ack = true and expired = false 65 | group by team_id, vuln_id 66 | })->expand->hashes->reduce(sub { $a->{$b->{team_id}}{$b->{vuln_id}} = $b->{flags}; $a; }, {}); 67 | 68 | my $teams = $db->select('teams', ['id'])->arrays->flatten; 69 | for my $team_id (@$teams) { 70 | for my $service (values %{$app->services}) { 71 | my $service_id = $service->{id}; 72 | my $n = $service->{vulns}->[$round % @{$service->{vulns}}]; 73 | my $vuln_id = $app->vulns->{$service_id}{$n}; 74 | 75 | if (!$active_services->{$service_id}) { 76 | $self->skip_check({team_id => $team_id, service_id => $service_id, vuln_id => $vuln_id}, 111, 'Service was disabled.'); 77 | $app->log->debug("Skip service #$service_id in round #$round for team #$team_id"); 78 | next; 79 | } 80 | 81 | if (my $s = $status->{$team_id}{$service_id}) { 82 | if ($round - $s->{round} <= 1 && !$s->{status}) { 83 | $self->skip_check({team_id => $team_id, service_id => $service_id, vuln_id => $vuln_id}); 84 | next; 85 | } 86 | } 87 | 88 | my $flag = $app->model('flag')->create($team_id); 89 | my $old_flag = c(@{$alive_flags->{$team_id}{$vuln_id}})->shuffle->first; 90 | 91 | $app->minion->enqueue( 92 | check => [$round, $team_id, $service_id, $flag, $old_flag, {n => $n, id => $vuln_id}], {queue => 'checker'} 93 | ); 94 | } 95 | } 96 | } 97 | 98 | sub get_monitor_status { 99 | my $self = shift; 100 | 101 | return $self->app->pg->db->query( 102 | 'select distinct on (team_id, service_id) * 103 | from monitor order by team_id, service_id, ts desc' 104 | )->hashes->reduce( 105 | sub { $a->{$b->{team_id}}{$b->{service_id}} = {round => $b->{round}, status => $b->{status}}; $a }, {} 106 | ); 107 | } 108 | 109 | sub skip_check { 110 | my ($self, $info, $code, $message) = @_; 111 | $code //= 104; 112 | $message //= 'Checker did not run, connect on port was failed.'; 113 | 114 | $self->app->pg->db->query( 115 | 'insert into runs (round, team_id, service_id, vuln_id, status, result) values (?, ?, ?, ?, ?, ?)', 116 | $self->round, $info->{team_id}, $info->{service_id}, $info->{vuln_id}, $code, 117 | {json => {error => $message}} 118 | ); 119 | } 120 | 121 | 1; 122 | -------------------------------------------------------------------------------- /lib/CS/Model/Scoreboard.pm: -------------------------------------------------------------------------------- 1 | package CS::Model::Scoreboard; 2 | use Mojo::Base 'MojoX::Model'; 3 | 4 | sub generate { 5 | my ($self, $round, $limit) = @_; 6 | my $db = $self->app->pg->db; 7 | 8 | $round //= $db->query('select max(round) from scores')->array->[0]; 9 | 10 | my $scoreboard = $db->query(' 11 | select 12 | t.host, t.network, t.name, t.details, s1.n - s.n as d, s.*, s1.services as old_services, s1.score as old_score 13 | from scoreboard as s 14 | join teams as t on s.team_id = t.id 15 | left join ( 16 | select * from scoreboard where round = case when $1-1<0 then 0 else $1-1 end 17 | ) as s1 using (team_id) 18 | where s.round = $1 order by n limit $2', $round, $limit) 19 | ->expand->hashes; 20 | 21 | my $services = $db->query(' 22 | select 23 | s.id, name, active, 24 | extract(epoch from ts_end - now())::float8 as disable_interval, 25 | phase, flag_base_amount, 26 | extract(epoch from ( 27 | select now() - ts 28 | from service_activity 29 | where phase = sa.phase and service_id = s.id 30 | order by round limit 1 31 | ))::float8 as phase_duration 32 | from service_activity as sa join services as s on sa.service_id = s.id 33 | where round = ? 34 | ', $round)->hashes->reduce(sub { 35 | $a->{$b->{id}} = { 36 | name => $b->{name}, 37 | active => $b->{active}, 38 | disable_interval => $b->{disable_interval}, 39 | phase => $b->{phase}, 40 | phase_duration => $b->{phase_duration}, 41 | flag_base_amount => $b->{flag_base_amount} 42 | }; 43 | $a 44 | }, {}); 45 | 46 | my ($game_status) = $self->app->model('util')->game_status; 47 | 48 | return { 49 | scoreboard => $scoreboard->to_array, 50 | round => $round, 51 | services => $services, 52 | game_status => $game_status 53 | }; 54 | } 55 | 56 | sub generate_history { 57 | my ($self, $round) = @_; 58 | my $db = $self->app->pg->db; 59 | 60 | $round //= $db->query('select max(round) from scores')->array->[0]; 61 | 62 | my $scoreboard = $db->query(q{ 63 | with a as ( 64 | select *, jsonb_array_elements(services) s 65 | from scoreboard where round <= $1 66 | ), 67 | b as ( 68 | select round, team_id, max(score) score, 69 | json_agg(json_build_object('flags', s->'flags', 'sflags', s->'sflags', 'fp', s->'fp', 'status', s->'status') order by s->'id') services 70 | from a 71 | group by round, team_id 72 | ) 73 | select round, json_agg(json_build_object('id', team_id, 'score', score, 'services', services)) scoreboard 74 | from b 75 | group by round order by round 76 | }, $round)->expand->hashes; 77 | 78 | return $scoreboard->to_array; 79 | } 80 | 81 | sub generate_for_team { 82 | my ($self, $team_id) = @_; 83 | my $db = $self->app->pg->db; 84 | 85 | my $round = $db->query('select max(round) from scores')->array->[0]; 86 | my $scoreboard = $db->query( 87 | q{ 88 | select t.host, t.name, s.* 89 | from scoreboard as s 90 | join teams as t on s.team_id = t.id 91 | where team_id = $1 order by round desc 92 | }, $team_id 93 | )->expand->hashes; 94 | 95 | return {scoreboard => $scoreboard->to_array, round => $round}; 96 | } 97 | 98 | sub generate_ctftime { 99 | return shift->app->pg->db->query(<expand->array->[0]; 109 | } 110 | 111 | sub generate_fb { 112 | return shift->app->pg->db->query(<hashes->to_array; 128 | } 129 | 130 | 1; 131 | -------------------------------------------------------------------------------- /lib/CS.pm: -------------------------------------------------------------------------------- 1 | package CS; 2 | use Mojo::Base 'Mojolicious'; 3 | 4 | use Mojo::Pg; 5 | 6 | has [qw/services vulns/] => sub { {} }; 7 | 8 | has ctf_name => sub { shift->config->{cs}{ctf_name} // 'CTF' }; 9 | 10 | has round_length => sub { shift->config->{cs}{round_length} // 60 }; 11 | has flag_life_time => sub { shift->config->{cs}{flag_life_time} // 15 }; 12 | 13 | sub startup { 14 | my $app = shift; 15 | 16 | push @{$app->commands->namespaces}, 'CS::Command'; 17 | 18 | $app->plugin('Config'); 19 | $app->plugin('Model'); 20 | 21 | if (my $static = $app->config->{cs}{static}) { 22 | push @{$app->static->paths}, @$static; 23 | } 24 | push @{$app->static->paths}, $ENV{CS_STATIC} if $ENV{CS_STATIC}; 25 | 26 | my $pg_uri = $ENV{POSTGRES_URI} // $app->config->{postgres_uri}; 27 | $app->plugin(Minion => {Pg => $pg_uri}); 28 | $app->helper( 29 | pg => sub { 30 | state $pg; 31 | unless ($pg) { 32 | $pg = Mojo::Pg->new($pg_uri)->max_connections(32)->auto_migrate(1); 33 | $pg->migrations->name('cs')->from_file($app->home->rel_file('cs.sql')); 34 | } 35 | return $pg; 36 | } 37 | ); 38 | 39 | # Tasks 40 | $app->minion->add_task(check => sub { $_[0]->app->model('checker')->check(@_) }); 41 | $app->minion->add_task( 42 | scoreboard => sub { 43 | my $app = shift->app; 44 | my $pg = $app->pg; 45 | 46 | $app->model('score')->update(@_); 47 | my $pubsub = $pg->pubsub->json('scoreboard'); 48 | $pubsub->notify('scoreboard'); 49 | } 50 | ); 51 | 52 | $app->init; 53 | 54 | # Routes 55 | my $r = $app->routes; 56 | 57 | # Optional frontend app from index.html 58 | if ($app->static->file('index.html')) { 59 | $r->get('/')->to(cb => sub { shift->reply->static('index.html') }); 60 | $r->get('/board')->to('main#index')->name('index'); 61 | } else { 62 | $r->get('/')->to('main#index')->name('index'); 63 | } 64 | $r->get('/team/:team_id')->to('main#team')->name('team'); 65 | $r->websocket('/update')->to('main#update')->name('update'); 66 | $r->get('/scoreboard' => [format => 'json'])->to('main#scoreboard')->name('scoreboard'); 67 | $r->get('/history/scoreboard' => [format => 'json'])->to('main#scoreboard_history') 68 | ->name('scoreboard_history'); 69 | $r->get('/ctftime/scoreboard' => [format => 'json'])->to('main#ctftime_scoreboard'); 70 | $r->get('/fb' => [format => 'json'])->to('main#fb'); 71 | $r->get('/t' => [format => 'json'])->to('main#t'); 72 | 73 | # Flags 74 | $r->put('/flags')->to('flags#put')->name('flags'); 75 | $r->get('/flag_ids')->to('flags#list')->name('flag_ids'); 76 | 77 | # API 78 | $r->websocket('/api/events')->to('api#events')->name('api_events'); 79 | $r->get('/api/info')->to('api#info')->name('api_info'); 80 | $r->get('/teams')->to('api#teams')->name('teams'); 81 | $r->get('/services')->to('api#services')->name('services'); 82 | 83 | # Admin 84 | my $admin = $r->under('/admin')->to('admin#auth'); 85 | $admin->get('/')->to('admin#index')->name('admin_index'); 86 | $admin->get('/info')->to('admin#info')->name('admin_info'); 87 | $admin->get('/view/:team_id/:service_id')->to('admin#view')->name('admin_view'); 88 | $admin->post('/teams')->to('admin#add_team'); 89 | 90 | # Minion Admin 91 | $app->plugin('Minion::Admin' => {route => $admin->any('/minion')}); 92 | 93 | $app->hook( 94 | before_dispatch => sub { 95 | my $c = shift; 96 | if (my $base_url = $c->config->{cs}{base_url}) { $c->req->url->base(Mojo::URL->new($base_url)); } 97 | } 98 | ); 99 | 100 | $app->hook(after_static => sub { shift->res->headers->cache_control('max-age=3600, must-revalidate'); }); 101 | } 102 | 103 | sub init { 104 | my $app = shift; 105 | my $db = $app->pg->db; 106 | 107 | if ($ENV{CS_DEBUG}) { 108 | $app->services($db->select('services')->hashes->reduce(sub { $a->{$b->{id}} = $b; $a }, {})); 109 | return; 110 | } 111 | 112 | my $services = $db->select('services')->hashes->reduce(sub { $a->{$b->{name}} = $b; $a }, {}); 113 | for (@{$app->config->{services}}) { 114 | next unless my $service = $services->{$_->{name}}; 115 | my @vulns = split /:/, $service->{vulns}; 116 | my $vulns; 117 | for my $n (0 .. $#vulns) { push @$vulns, $n + 1 for 1 .. $vulns[$n] } 118 | $app->services->{$service->{id}} = {%$_, %$service, vulns => $vulns}; 119 | } 120 | 121 | my $vulns = 122 | $db->select('vulns')->hashes->reduce(sub { $a->{$b->{service_id}}{$b->{n}} = $b->{id}; $a }, {}); 123 | $app->vulns($vulns); 124 | } 125 | 126 | 1; 127 | -------------------------------------------------------------------------------- /lib/CS/Model/Score.pm: -------------------------------------------------------------------------------- 1 | package CS::Model::Score; 2 | use Mojo::Base 'MojoX::Model'; 3 | 4 | use List::Util 'min'; 5 | 6 | sub update { 7 | my ($self, $round) = @_; 8 | 9 | my $db = $self->app->pg->db; 10 | my $tx = $db->begin; 11 | unless ($db->query('select pg_try_advisory_xact_lock(1)')->array->[0]) { 12 | $self->app->log->warn("Can't update scores, another update in action"); 13 | return; 14 | } 15 | 16 | my $r = $db->select(scores => 'max(round) + 1')->array->[0] // 0; 17 | $round //= $db->select(rounds => 'max(n) - 1')->array->[0]; 18 | for ($r .. $round) { 19 | $self->sla($db, $_); 20 | $self->flag_points($db, $_); 21 | $self->scoreboard($db, $_); 22 | } 23 | $tx->commit; 24 | } 25 | 26 | sub scoreboard { 27 | my ($self, $db, $r) = @_; 28 | $self->app->log->info("Calc scoreboard for round #$r"); 29 | $db->query( 30 | q{ 31 | insert into scores 32 | select 33 | $1 as round, team_id, service_id, sla, fp, 34 | coalesce(f.flags, 0) as flags, coalesce(sf.flags, 0) as sflags, coalesce(status, 110), stdout 35 | from 36 | (select team_id, service_id, amount as fp from flag_points where round = $1) as fp 37 | join ( 38 | select team_id, service_id, 39 | case when successed + failed = 0 then 1 else (successed::float8 / (successed + failed)) end as sla 40 | from sla where round = $1 41 | ) as s using (team_id, service_id) 42 | left join ( 43 | select sf.team_id, f.service_id, count(sf.data) as flags 44 | from stolen_flags as sf join flags as f using (data) 45 | where sf.round <= $1 46 | group by sf.team_id, f.service_id 47 | ) as f using (team_id, service_id) 48 | left join ( 49 | select f.team_id, f.service_id, count(sf.data) as flags 50 | from stolen_flags as sf join flags as f using (data) 51 | where sf.round <= $1 52 | group by f.team_id, f.service_id 53 | ) as sf using (team_id, service_id) 54 | left join ( 55 | select team_id, service_id, status, stdout from runs where round = $1 56 | ) as r using (team_id, service_id) 57 | }, $r 58 | ); 59 | $db->query( 60 | q{ 61 | insert into scoreboard 62 | select round, team_id, round(sum(sla * fp)::numeric, 2) as score, 63 | rank() over(order by sum(sla * fp) desc) as n, 64 | json_agg(json_build_object( 65 | 'id', service_id, 66 | 'flags', flags, 67 | 'sflags', sflags, 68 | 'fp', round(fp::numeric, 2), 69 | 'sla', round(100 * sla::numeric, 2), 70 | 'status', status, 71 | 'stdout', stdout 72 | ) order by service_id) as services 73 | from scores where round = $1 group by round, team_id; 74 | }, $r 75 | ); 76 | } 77 | 78 | sub sla { 79 | my ($self, $db, $r) = @_; 80 | $self->app->log->info("Calc SLA for round #$r"); 81 | 82 | my $state = $db->select(sla => '*', {round => $r - 1}) 83 | ->hashes->reduce(sub { ++$b->{round}; $a->{$b->{team_id}}{$b->{service_id}} = $b; $a; }, {}); 84 | 85 | $db->query(' 86 | with r as (select team_id, service_id, status from runs where round = ?), 87 | teams_x_services as ( 88 | select teams.id as team_id, services.id as service_id 89 | from teams cross join services 90 | ) 91 | select * from teams_x_services left join r using (team_id, service_id)', $r)->hashes->map( 92 | sub { 93 | my $status = $_->{status} // 110; 94 | 95 | # Skip inactive services or checker errors 96 | return if $status == 111 || $status == 110; 97 | 98 | my $field = $status == 101 ? 'successed' : 'failed'; 99 | ++$state->{$_->{team_id}}{$_->{service_id}}{$field}; 100 | } 101 | ); 102 | 103 | for my $team_id (keys %$state) { 104 | for my $service_id (keys %{$state->{$team_id}}) { 105 | $db->insert(sla => $state->{$team_id}{$service_id}); 106 | } 107 | } 108 | } 109 | 110 | sub flag_points { 111 | my ($self, $db, $r) = @_; 112 | $self->app->log->info("Calc FP for round #$r"); 113 | 114 | my $state = $db->select(flag_points => '*', {round => $r - 1}) 115 | ->hashes->reduce(sub { ++$b->{round}; $a->{$b->{team_id}}{$b->{service_id}} = $b; $a; }, {}); 116 | my $flags = $db->query(q{ 117 | select f.data, f.service_id, f.team_id as victim_id, sf.team_id, sf.amount 118 | from flags as f join stolen_flags as sf using (data) 119 | where sf.round = ? order by sf.ts asc 120 | }, $r)->hashes; 121 | 122 | for my $flag (@$flags) { 123 | $state->{$flag->{team_id}}{$flag->{service_id}}{amount} += $flag->{amount}; 124 | $state->{$flag->{victim_id}}{$flag->{service_id}}{amount} -= 125 | min($flag->{amount}, $state->{$flag->{victim_id}}{$flag->{service_id}}{amount}); 126 | } 127 | 128 | for my $team_id (keys %$state) { 129 | for my $service_id (keys %{$state->{$team_id}}) { 130 | $db->insert(flag_points => $state->{$team_id}{$service_id}); 131 | } 132 | } 133 | } 134 | 135 | 1; 136 | -------------------------------------------------------------------------------- /lib/CS/Controller/Admin.pm: -------------------------------------------------------------------------------- 1 | package CS::Controller::Admin; 2 | use Mojo::Base 'Mojolicious::Controller'; 3 | 4 | use List::Util 'all'; 5 | use Mojo::JSON 'j'; 6 | use Mojo::Util 'b64_decode', 'tablify'; 7 | 8 | sub auth { 9 | my $c = shift; 10 | 11 | my $auth = $c->req->headers->authorization // ''; 12 | unless ($auth) { 13 | $c->res->headers->www_authenticate('Basic'); 14 | $c->render(text => 'Authentication required!', status => 401); 15 | return undef; 16 | } 17 | $auth =~ s/^Basic\s//; 18 | my $line = b64_decode $auth; 19 | 20 | return 1 if $line eq $c->config->{cs}{admin_auth}; 21 | 22 | $c->res->headers->www_authenticate('Basic'); 23 | $c->render(text => 'Authentication required!', status => 401); 24 | return undef; 25 | } 26 | 27 | sub add_team { 28 | my $c = shift; 29 | 30 | my $team_info = $c->req->json // {}; 31 | for my $field (qw/name network host token/) { 32 | return $c->render(json => {status => 'FAIL', info => "Field '$field' is required"}) 33 | unless length $team_info->{$field}; 34 | } 35 | 36 | eval { $c->app->commands->run(add_team => j($team_info)) }; 37 | 38 | $c->render(json => {status => $@ ? 'FAIL' : 'OK', info => $@ ? "$@" : ''}); 39 | } 40 | 41 | sub info { 42 | my $c = shift; 43 | my $db = $c->pg->db; 44 | 45 | # Game status 46 | my $time = $c->config->{cs}{time} // []; 47 | my $range = join ',', map "'[$_->[0], $_->[1]]'", @$time; 48 | my $sql = <<"SQL"; 49 | with tmp as ( 50 | select *, (select max(n) from rounds where ts < lower(range)) as r 51 | from (select unnest(array[$range]::tstzrange[]) as range) as tmp 52 | ) 53 | select 54 | range, r + 1 as r, 55 | now() <@ range as live, 56 | now() < lower(range) as before, 57 | now() > upper(range) as finish 58 | from tmp 59 | SQL 60 | my $game_status = $c->_tablify($db->query($sql)); 61 | 62 | # Services 63 | my $services = $c->_tablify($db->query('table services')); 64 | 65 | # Installed flags 66 | $sql = <_tablify($db->query($sql)); 74 | 75 | # Stolen flags 76 | $sql = <_tablify($db->query($sql)); 88 | 89 | # Hackers 90 | $sql = < 0) as hackers 94 | from scores 95 | where round = (select max(round) from scores) 96 | group by service_id 97 | order by 3 desc 98 | SQL 99 | my $hackers = $c->_tablify($db->query($sql)); 100 | 101 | # First bloods 102 | $sql = <_tablify($db->query($sql)); 119 | 120 | $c->render( 121 | now => scalar(localtime), 122 | game_status => $game_status, 123 | tables => [ 124 | {name => 'Installed flags', data => $installed_flags}, 125 | {name => 'Stolen flags', data => $stolen_flags}, 126 | {name => 'First bloods', data => $fb}, 127 | {name => 'Services', data => $services}, 128 | {name => 'Hackers', data => $hackers} 129 | ] 130 | ); 131 | } 132 | 133 | sub index { $_[0]->render(%{$_[0]->model('scoreboard')->generate}) } 134 | 135 | sub view { 136 | my $c = shift; 137 | my $db = $c->pg->db; 138 | 139 | my $team = $c->param('team_id') eq '*' ? {} : $db->select('teams', undef, {id => $c->param('team_id')})->expand->hash; 140 | my $service = $c->app->services->{$c->param('service_id')}; 141 | 142 | return $c->reply->not_found 143 | unless ($team->{id} || ($c->param('team_id') eq '*')) 144 | && ($service->{id} || ($c->param('service_id') eq '*')); 145 | 146 | my $status = $c->param('status'); 147 | if ($status) { 148 | $status = undef if $status eq 'all' || all { $status != $_ } (101, 102, 103, 104, 110); 149 | } 150 | 151 | my $last = $db->query( 152 | 'select count(*) 153 | from runs where 154 | (team_id = $1 or $1 is null) and (service_id = $2 or $2 is null) and (status = $3 or $3 is null)', 155 | $team->{id}, $service->{id}, $status 156 | )->array->[0]; 157 | my $limit = int($c->param('limit') // 0) || 30; 158 | my $max = int($last / $limit) + 1; 159 | 160 | my $page = int($c->param('page') // 1); 161 | $page = 1 if $page < 1; 162 | $page = $max if $page > $max; 163 | my $offset = ($page - 1) * $limit; 164 | 165 | my $view = $db->query( 166 | 'select round, status, result 167 | from runs where 168 | (team_id = $1 or $1 is null) and (service_id = $2 or $2 is null) and (status = $3 or $3 is null) 169 | order by round desc limit $4 offset $5', $team->{id}, $service->{id}, $status, $limit, $offset 170 | )->expand->hashes->to_array; 171 | $c->render(view => $view, page => $page, max => $max, team_name => $team->{name} // '*'); 172 | } 173 | 174 | sub _tablify { 175 | my ($c, $result) = @_; 176 | 177 | my $r = $result->arrays; 178 | unshift @$r, $result->columns; 179 | return tablify($r); 180 | } 181 | 182 | 1; 183 | -------------------------------------------------------------------------------- /lib/CS/Model/Checker.pm: -------------------------------------------------------------------------------- 1 | package CS::Model::Checker; 2 | use Mojo::Base 'MojoX::Model'; 3 | 4 | use IPC::Run qw/start timeout/; 5 | use List::Util qw/all min/; 6 | use Mojo::Collection 'c'; 7 | use Mojo::File 'path'; 8 | use Mojo::JSON 'j'; 9 | use Mojo::Util qw/dumper trim/; 10 | use Proc::Killfam; 11 | use Time::HiRes qw/gettimeofday tv_interval/; 12 | 13 | use constant MAX_OUTPUT_LENGTH => 100 * 1024; 14 | 15 | # Internal statuses 16 | # 110 -- checker error 17 | # 111 -- service was disabled in this round 18 | has statuses => sub { [[up => 101], [corrupt => 102], [mumble => 103], [down => 104]] }; 19 | has status2name => sub { 20 | return {map { $_->[1] => $_->[0] } @{$_[0]->statuses}}; 21 | }; 22 | 23 | sub info { 24 | my ($self, $service) = @_; 25 | 26 | my $result = {vulns => {count => 1, distribution => '1'}, public_flag_description => undef}; 27 | 28 | my $info = $self->_run([$service->{path}, 'info'], $service->{timeout}); 29 | return $result unless $info->{exit_code} == 101; 30 | 31 | # vulns 32 | $info->{stdout} =~ /^vulns:(.*)$/m; 33 | my $vulns = trim($1 // ''); 34 | if ($vulns =~ /^[0-9:]+$/) { 35 | $result->{vulns}{count} = 0 + split(/:/, $vulns); 36 | $result->{vulns}{distribution} = $vulns; 37 | } 38 | 39 | # flag description 40 | $info->{stdout} =~ /^public_flag_description:(.*)$/m; 41 | $result->{public_flag_description} = trim($1) if $1; 42 | 43 | return $result; 44 | } 45 | 46 | sub check { 47 | my ($self, $job, $round, $team_id, $service_id, $flag, $old_flag, $vuln) = @_; 48 | my $result = {vuln => $vuln}; 49 | my $db = $job->app->pg->db; 50 | 51 | my $team = $db->select('teams', undef, {id => $team_id})->expand->hash; 52 | my $service = $db->select('services', undef, {id => $service_id})->expand->hash; 53 | 54 | my $cmd; 55 | my $host = $job->app->model('util')->get_service_host($team, $service); 56 | 57 | for (@{c(qw/check put_get get2/)->shuffle}) { 58 | my $timeout = min($service->{timeout}, $self->_next_round_start($db, $round)); 59 | 60 | if ($_ eq 'check') { 61 | $cmd = [$service->{path}, 'check', $host]; 62 | $result->{check} = $self->_run($cmd, $timeout); 63 | return $self->_finish($job, $result, $db) if $result->{check}{slow} || $result->{check}{exit_code} != 101; 64 | } elsif ($_ eq 'put_get') { 65 | my $flag_row = { 66 | data => $flag->{data}, 67 | id => $flag->{id}, 68 | round => $round, 69 | team_id => $team_id, 70 | service_id => $service_id, 71 | vuln_id => $vuln->{id} 72 | }; 73 | $db->insert(flags => $flag_row); 74 | 75 | $cmd = [$service->{path}, 'put', $host, $flag->{id}, $flag->{data}, $vuln->{n}]; 76 | $result->{put} = $self->_run($cmd, $timeout); 77 | return $self->_finish($job, $result, $db) if $result->{put}{slow} || $result->{put}{exit_code} != 101; 78 | 79 | $flag_row = {ack => 'true'}; 80 | (my $new_id = $result->{put}{stdout}) =~ s/\r?\n$//; 81 | if ($new_id) { 82 | $flag_row->{id} = $flag->{id} = $new_id; 83 | if (my $new_json_id = j($new_id)) { 84 | $flag_row->{public_id} = $new_json_id->{public_flag_id} if ref $new_json_id eq 'HASH'; 85 | } 86 | } 87 | 88 | $db->update(flags => $flag_row => {data => $flag->{data}}); 89 | 90 | $cmd = [$service->{path}, 'get', $host, $flag->{id}, $flag->{data}, $vuln->{n}]; 91 | $result->{get_1} = $self->_run($cmd, $timeout); 92 | return $self->_finish($job, $result, $db) if $result->{get_1}{slow} || $result->{get_1}{exit_code} != 101; 93 | } elsif ($_ eq 'get2') { 94 | if ($old_flag) { 95 | $cmd = [$service->{path}, 'get', $host, $old_flag->{id}, $old_flag->{data}, $vuln->{n}]; 96 | $result->{get_2} = $self->_run($cmd, $timeout); 97 | return $self->_finish($job, $result, $db) if $result->{get_2}{slow} || $result->{get_2}{exit_code} != 101;; 98 | } 99 | } 100 | } 101 | 102 | return $self->_finish($job, $result, $db); 103 | } 104 | 105 | sub _finish { 106 | my ($self, $job, $result, $db) = @_; 107 | 108 | my ($round, $team_id, $service_id, $flag, undef, $vuln) = @{$job->args}; 109 | my ($stdout, $status) = (''); 110 | 111 | # Prepare result for runs 112 | if (c(qw/get_2 get_1 put check/)->first(sub { defined $result->{$_}{slow} })) { 113 | $result->{error} = 'Job is too old!'; 114 | $status = 104; 115 | } else { 116 | my $state = c(qw/get_2 get_1 put check/) 117 | ->grep(sub { defined $result->{$_}{exit_code} }) 118 | ->sort(sub { $result->{$a}{exit_code} <=> $result->{$b}{exit_code} }) 119 | ->last; 120 | $status = $result->{$state}{exit_code}; 121 | $stdout = $result->{$state}{stdout} if $status != 101; 122 | } 123 | 124 | $job->finish($result); 125 | 126 | my $run = { 127 | round => $round, 128 | team_id => $team_id, 129 | service_id => $service_id, 130 | vuln_id => $vuln->{id}, 131 | status => $status, 132 | result => j($result), 133 | stdout => $stdout 134 | }; 135 | $db->insert(runs => $run); 136 | } 137 | 138 | sub _next_round_start { 139 | my ($self, $db, $round) = @_; 140 | 141 | return $db->query('select extract(epoch from ts + ?::interval - now()) from rounds where n = ?', 142 | $self->app->round_length, $round)->array->[0]; 143 | } 144 | 145 | sub _run { 146 | my ($self, $cmd, $timeout) = @_; 147 | my ($stdout, $stderr); 148 | 149 | return {slow => 1} if $timeout <= 0; 150 | 151 | my $path = path($cmd->[0])->to_abs; 152 | $cmd->[0] = $path->to_string; 153 | 154 | $self->app->log->debug("Run '@$cmd' with timeout $timeout"); 155 | my ($t, $h) = timeout($timeout); 156 | my $start = [gettimeofday]; 157 | eval { 158 | $h = start $cmd, \undef, \$stdout, \$stderr, 'init', sub { chdir $path->dirname }, $t; 159 | $h->finish; 160 | }; 161 | 162 | $stdout //= ''; 163 | $stdout =~ s/\x00//g; 164 | utf8::decode $stdout; 165 | 166 | $stderr //= ''; 167 | $stderr =~ s/\x00//g; 168 | utf8::decode $stderr; 169 | 170 | if (length $stdout > MAX_OUTPUT_LENGTH) { 171 | $self->app->log->warn("Length of STDOUT for '@$cmd' exceeds limit"); 172 | $stdout = substr($stdout, 0, MAX_OUTPUT_LENGTH) . "..."; 173 | } 174 | 175 | my $result = { 176 | command => "@$cmd", 177 | elapsed => tv_interval($start), 178 | exception => $@, 179 | exit => {value => $?, code => $? >> 8, signal => $? & 127, coredump => $? & 128}, 180 | stderr => $stderr, 181 | stdout => $stdout, 182 | timeout => 0 183 | }; 184 | $result->{exit_code} = ($@ || all { $? >> 8 != $_ } (101, 102, 103, 104)) ? 110 : $? >> 8; 185 | 186 | if ($@ && $@ =~ /timeout/i) { 187 | $result->{timeout} = 1; 188 | $result->{exit_code} = 104; 189 | my $pid = $h->{KIDS}[0]{PID}; 190 | my $n = killfam 9, $pid; 191 | $self->app->log->debug("Kill all sub process for $pid => $n"); 192 | } 193 | 194 | $result->{ts} = scalar(localtime); 195 | return $result; 196 | } 197 | 198 | 1; 199 | -------------------------------------------------------------------------------- /lib/CS/Model/Util.pm: -------------------------------------------------------------------------------- 1 | package CS::Model::Util; 2 | use Mojo::Base 'MojoX::Model'; 3 | 4 | use List::Util 'min'; 5 | 6 | sub update_service_phases { 7 | my ($self, $r) = @_; 8 | 9 | my $app = $self->app; 10 | my $scoring = $app->config->{cs}{scoring}; 11 | my $db = $app->pg->db; 12 | my $tx = $db->begin; 13 | 14 | my $active_services = $db->query(q{ 15 | insert into service_activity (round, service_id, phase, active) 16 | select 17 | ?, id, 'NOT_RELEASED', 18 | now() between coalesce(ts_start, '-infinity') and coalesce(ts_end, 'infinity') 19 | from services 20 | returning service_id, active 21 | }, $r)->hashes->reduce(sub { $a->{$b->{service_id}} = $b->{active}; $a }, {}); 22 | 23 | for my $service (values %{$app->services}) { 24 | my $prev_filter = {service_id => $service->{id}, round => $r - 1}; 25 | my $current_filter = {service_id => $service->{id}, round => $r}; 26 | 27 | my $prev_phase = $db->select(service_activity => ['phase'], $prev_filter)->array->[0]; 28 | my $prev_base_amount = $db->select(service_activity => ['flag_base_amount'], $prev_filter)->array->[0]; 29 | my ($new_phase, $new_base_amount); 30 | 31 | if (!$active_services->{$service->{id}}) { 32 | $db->update(service_activity => 33 | {phase => $prev_phase, flag_base_amount => $prev_base_amount}, 34 | $current_filter 35 | ); 36 | next; 37 | } 38 | 39 | if ($prev_phase eq 'NOT_RELEASED') { 40 | $new_phase = 'HEATING'; 41 | $new_base_amount = $scoring->{start_flag_price}; 42 | } elsif ($prev_phase eq 'HEATING') { 43 | my $uniq_submissions = $db->query(q{ 44 | select count(distinct(data)) 45 | from stolen_flags as sf join flags as f using (data) 46 | where service_id = ? 47 | }, $service->{id})->array->[0]; 48 | 49 | $new_phase = $uniq_submissions >= $scoring->{heating_flags_limit} ? 'COOLING_DOWN' : 'HEATING'; 50 | 51 | if ($new_phase eq 'COOLING_DOWN') { 52 | $new_base_amount = $prev_base_amount * $scoring->{cooling_down}; 53 | } else { 54 | $new_base_amount = min($prev_base_amount + $scoring->{heating_speed}, $scoring->{max_flag_price}); 55 | } 56 | } elsif ($prev_phase eq 'COOLING_DOWN') { 57 | my $cooling_phase = $db->query(q{ 58 | select round, flag_base_amount 59 | from service_activity 60 | where service_id = ? and phase = 'COOLING_DOWN' 61 | order by round limit 1 62 | }, $service->{id})->hash; 63 | 64 | my $submissions = $db->query(q{ 65 | select count(*) 66 | from stolen_flags as sf join flags as f using (data) 67 | where service_id = $1 and sf.round >= $2 68 | }, $service->{id}, $cooling_phase->{round})->array->[0]; 69 | 70 | $new_phase = $submissions >= $scoring->{cooling_submissions_limit} ? 'DYING' : 'COOLING_DOWN'; 71 | 72 | if ($new_phase eq 'DYING') { 73 | $new_base_amount = $scoring->{dying_flag_price}; 74 | 75 | # start dying timer 76 | my $interval = $scoring->{dying_rounds} * $app->round_length - $app->round_length / 2; 77 | $db->query(q{ 78 | update services 79 | set ts_end = now() + (interval '1 seconds' * $2::integer) 80 | where id = $1 and ts_end is null 81 | }, $service->{id}, $interval); 82 | } else { 83 | my $start_amount = $cooling_phase->{flag_base_amount}; 84 | $new_base_amount = $start_amount + 85 | $submissions * ($scoring->{dying_flag_price} - $start_amount) / $scoring->{cooling_submissions_limit}; 86 | } 87 | } elsif ($prev_phase eq 'DYING') { 88 | my $current_dying_rounds = $db->query(q{ 89 | select count(*) 90 | from service_activity 91 | where service_id = ? and phase = 'DYING' 92 | }, $service->{id})->array->[0]; 93 | 94 | $new_phase = $current_dying_rounds < $scoring->{dying_rounds} ? 'DYING' : 'REMOVED'; 95 | $new_base_amount = $new_phase eq 'REMOVED' ? 0 : $scoring->{dying_flag_price}; 96 | } elsif ($prev_phase eq 'REMOVED') { 97 | $new_phase = 'REMOVED'; 98 | $new_base_amount = 0; 99 | } 100 | 101 | $db->update(service_activity => {phase => $new_phase, flag_base_amount => $new_base_amount}, $current_filter); 102 | } 103 | 104 | $tx->commit; 105 | 106 | return $active_services; 107 | } 108 | 109 | sub get_active_services { 110 | my $self = shift; 111 | 112 | return $self->app->pg->db->query(q{ 113 | select id, name 114 | from services 115 | where now() between coalesce(ts_start, '-infinity') and coalesce(ts_end, 'infinity') 116 | })->hashes->reduce(sub { $a->{$b->{id}} = $b->{name}; $a }, {}); 117 | } 118 | 119 | sub get_service_host { 120 | my ($self, $team, $service) = @_; 121 | 122 | my $host = $team->{host}; 123 | if (my $cb = $self->app->config->{cs}{checkers}{hostname}) { 124 | $host = $cb->($team, $service) 125 | } 126 | 127 | return $host; 128 | } 129 | 130 | sub get_service_ip { 131 | my ($self, $team, $service) = @_; 132 | 133 | my $ip = $team->{host}; 134 | 135 | if ($team->{details}{ip_prefix} && $service->{ip_suffix}) { 136 | $ip = "$team->{details}{ip_prefix}$service->{ip_suffix}"; 137 | } 138 | 139 | return $ip; 140 | } 141 | 142 | sub game_time { 143 | my $self = shift; 144 | 145 | my $time = $self->app->config->{cs}{time} // []; 146 | my ($start, $end) = ($time->[0][0], $time->[-1][1]); 147 | 148 | my $result = $self->app->pg->db->query(q{ 149 | select 150 | extract(epoch from ?::timestamptz)::float8 as start, 151 | extract(epoch from ?::timestamptz)::float8 as end 152 | }, $start, $end)->hash; 153 | 154 | return {start => $result->{start}, end => $result->{end}}; 155 | } 156 | 157 | sub game_duration { 158 | my $self = shift; 159 | 160 | my $time = $self->app->config->{cs}{time} // []; 161 | my $range = join ',', map "'[$_->[0], $_->[1]]'", @$time; 162 | 163 | my $duration = $self->app->pg->db->query(qq{ 164 | select extract(epoch from sum(upper(range)-lower(range))) 165 | from (select unnest(array[$range]::tstzrange[]) as range) as tmp 166 | })->array->[0] // 0; 167 | 168 | return $duration / $self->app->round_length; 169 | } 170 | 171 | sub game_status { 172 | my $self = shift; 173 | 174 | my $db = $self->app->pg->db; 175 | my $time = $self->app->config->{cs}{time}; 176 | 177 | return (1, $db->select(rounds => 'max(n)')->array->[0]) unless $time; 178 | 179 | my $range = join ',', map "'[$_->[0], $_->[1]]'", @$time; 180 | my $sql = <<"SQL"; 181 | with tmp as ( 182 | select *, (select max(n) from rounds where ts < lower(range)) as r 183 | from (select unnest(array[$range]::tstzrange[]) as range) as tmp 184 | where lower(range) <= now() 185 | ) 186 | select 187 | bool_or(now() <@ range) as live, 188 | bool_and(now() < lower(range)) as before, 189 | bool_and(now() > upper(range)) as finish, 190 | max(r) + 1 as round 191 | from tmp 192 | SQL 193 | my $result = $db->query($sql)->hash; 194 | 195 | return -1 if $result->{finish}; 196 | return 0 if $result->{before}; 197 | return (1, $result->{round}) if $result->{live}; 198 | return 0; # break 199 | } 200 | 201 | 1; 202 | -------------------------------------------------------------------------------- /cs.sql: -------------------------------------------------------------------------------- 1 | -- 1 up (init) 2 | create table teams ( 3 | id serial primary key, 4 | name text not null unique, 5 | network cidr not null, 6 | host text not null, 7 | token text unique, 8 | details jsonb not null default '{}' 9 | ); 10 | 11 | create table services ( 12 | id serial primary key, 13 | name text not null unique, 14 | vulns text not null, 15 | ts_start timestamptz, 16 | ts_end timestamptz, 17 | timeout float8, 18 | path text, 19 | public_flag_description text 20 | ); 21 | 22 | create table vulns ( 23 | id serial primary key, 24 | service_id integer not null references services(id), 25 | n smallint not null check (n > 0), 26 | unique (service_id, n) 27 | ); 28 | 29 | create table rounds ( 30 | n integer primary key, 31 | ts timestamptz not null default now() 32 | ); 33 | 34 | create type service_phase as enum ('NOT_RELEASED', 'HEATING', 'COOLING_DOWN', 'DYING', 'REMOVED'); 35 | create table service_activity ( 36 | id serial primary key, 37 | ts timestamptz not null default now(), 38 | round integer not null references rounds(n), 39 | service_id integer not null references services(id), 40 | active boolean not null, 41 | flag_base_amount float8 not null default 0, 42 | phase service_phase not null, 43 | unique (round, service_id) 44 | ); 45 | create index on service_activity (service_id, phase); 46 | 47 | create table flags ( 48 | data text primary key, 49 | id text not null, 50 | public_id text, 51 | round integer not null references rounds(n), 52 | ts timestamptz not null default now(), 53 | team_id integer not null references teams(id), 54 | service_id integer not null references services(id), 55 | vuln_id integer not null references vulns(id), 56 | ack boolean not null default false, 57 | expired boolean not null default false, 58 | unique (round, team_id, service_id) 59 | ); 60 | create index on flags (expired, service_id); 61 | 62 | create table stolen_flags ( 63 | data text not null references flags(data), 64 | ts timestamptz not null default now(), 65 | round integer not null references rounds(n), 66 | team_id integer not null references teams(id), 67 | amount float8 not null, 68 | unique (data, team_id) 69 | ); 70 | create index on stolen_flags (data, team_id); 71 | 72 | create table runs ( 73 | round integer not null references rounds(n), 74 | ts timestamptz not null default now(), 75 | team_id integer not null references teams(id), 76 | service_id integer not null references services(id), 77 | vuln_id integer not null references vulns(id), 78 | status integer not null, 79 | result jsonb, 80 | stdout text, 81 | unique (round, team_id, service_id) 82 | ); 83 | create index on runs (round); 84 | 85 | create table sla ( 86 | round integer not null references rounds(n), 87 | team_id integer not null references teams(id), 88 | service_id integer not null references services(id), 89 | successed integer not null, 90 | failed integer not null, 91 | unique (round, team_id, service_id) 92 | ); 93 | create index on sla (round); 94 | 95 | create table flag_points ( 96 | round integer not null references rounds(n), 97 | team_id integer not null references teams(id), 98 | service_id integer not null references services(id), 99 | amount float8 not null, 100 | unique (round, team_id, service_id) 101 | ); 102 | create index on flag_points (round); 103 | 104 | create table monitor ( 105 | round integer not null references rounds(n), 106 | ts timestamptz not null default now(), 107 | team_id integer not null references teams(id), 108 | service_id integer not null references services(id), 109 | status boolean not null, 110 | error text 111 | ); 112 | 113 | create table scores ( 114 | round integer not null references rounds(n), 115 | team_id integer not null references teams(id), 116 | service_id integer not null references services(id), 117 | sla float8 not null, 118 | fp float8 not null, 119 | flags integer not null, 120 | sflags integer not null, 121 | status integer not null, 122 | stdout text, 123 | unique (round, team_id, service_id) 124 | ); 125 | create index on scores (round); 126 | 127 | create table scoreboard ( 128 | round integer not null references rounds(n), 129 | team_id integer not null references teams(id), 130 | score numeric not null, 131 | n smallint not null, 132 | services jsonb not null, 133 | unique (round, team_id) 134 | ); 135 | create index on scoreboard (round); 136 | create index on scoreboard (team_id); 137 | 138 | create function accept_flag(team_id integer, flag_data text) returns record as $$ 139 | <> 140 | declare 141 | flag flags%rowtype; 142 | round rounds.n%type; 143 | amount stolen_flags.amount%type; 144 | 145 | attacker_pos smallint; 146 | victim_pos smallint; 147 | amount_max float8; 148 | teams_count smallint; 149 | 150 | service_active boolean; 151 | begin 152 | select * from flags where data = flag_data into flag; 153 | 154 | if not found then return row(false, 'Denied: no such flag'); end if; 155 | if team_id = flag.team_id then return row(false, 'Denied: invalid or own flag'); end if; 156 | if flag.expired then return row(false, 'Denied: flag is too old'); end if; 157 | 158 | select now() between coalesce(ts_start, '-infinity') and coalesce(ts_end, 'infinity') 159 | from services where id = flag.service_id into service_active; 160 | if not service_active then return row(false, 'Denied: service inactive'); end if; 161 | 162 | perform * from stolen_flags as sf where sf.data = flag_data and sf.team_id = accept_flag.team_id; 163 | if found then return row(false, 'Denied: you already submitted this flag'); end if; 164 | 165 | select max(s.round) into round from scoreboard as s; 166 | select n from scoreboard as s where s.round = my.round - 1 and s.team_id = accept_flag.team_id into attacker_pos; 167 | select n from scoreboard as s where s.round = my.round - 1 and s.team_id = flag.team_id into victim_pos; 168 | 169 | select count(*) from teams into teams_count; 170 | select flag_base_amount into amount_max 171 | from service_activity as sa 172 | where sa.service_id = flag.service_id and sa.round = flag.round; 173 | 174 | amount = case when attacker_pos >= victim_pos 175 | then amount_max 176 | else amount_max ^ (1 - ((victim_pos - attacker_pos) / (teams_count - 1))) 177 | end; 178 | 179 | select max(n) into round from rounds; 180 | insert into stolen_flags (data, team_id, round, amount) 181 | values (flag_data, team_id, round, amount) on conflict do nothing; 182 | if not found then return row(false, 'Denied: you already submitted this flag'); end if; 183 | 184 | return row(true, null, round, flag.team_id, flag.service_id, amount); 185 | end; 186 | $$ language plpgsql; 187 | -- 1 down 188 | drop function if exists accept_flag(integer, text); 189 | drop table if exists rounds, monitor, scores, teams, vulns, services, service_activity, flags, 190 | stolen_flags, runs, sla, flag_points, scoreboard; 191 | drop type if exists service_phase; 192 | -------------------------------------------------------------------------------- /t/basic.t: -------------------------------------------------------------------------------- 1 | use Mojo::Base -strict; 2 | 3 | use Test::Mojo; 4 | use Test::More; 5 | 6 | use Mojo::Collection 'c'; 7 | 8 | use CS::Command::manager; 9 | 10 | BEGIN { $ENV{MOJO_CONFIG} = 'cs.test.conf' } 11 | 12 | my $t = Test::Mojo->new('CS'); 13 | my $app = $t->app; 14 | my $db = $app->pg->db; 15 | 16 | diag('Init'); 17 | 18 | $app->commands->run('reset_db'); 19 | $app->commands->run('init_db'); 20 | $app->init; 21 | 22 | my $up1 = $db->select(services => '*', {name => 'up1'})->hash; 23 | is $up1->{vulns}, '1:1:2', 'right vulns'; 24 | is $up1->{public_flag_description}, 'user profile', 'right flag description in db'; 25 | is $app->services->{3}{public_flag_description}, 'user profile', 'right flag description in app'; 26 | 27 | my $team1 = $db->select('teams', undef, {id => 1})->expand->hash; 28 | is_deeply $team1->{details}{tags}, ['edu', 'online', 'Russia'], 'right tags for team'; 29 | 30 | my $manager = CS::Command::manager->new(app => $app); 31 | 32 | diag('New round #1'); 33 | 34 | # Disable up2 35 | $db->update('services', {ts_start => \"now() + interval '10 minutes'", ts_end => undef}, {name => 'up2'}); 36 | 37 | $manager->start_round; 38 | is $manager->round, 1, 'right round'; 39 | $app->minion->perform_jobs({queues => ['default', 'checker']}); 40 | $app->model('score')->update; 41 | 42 | # Runs (3 active services * 3 teams) 43 | is $db->query('select count(*) from runs')->array->[0], 12, 'right numbers of runs'; 44 | 45 | # Service down1 46 | $db->select(runs => '*', {service_id => 1, team_id => 1})->expand->hashes->map( 47 | sub { 48 | is $_->{round}, 1, 'right round'; 49 | is $_->{status}, 104, 'right status'; 50 | is $_->{stdout}, "some error 😉\n", 'right stdout'; 51 | } 52 | ); 53 | 54 | # Service down2 55 | $db->select(runs => '*', {service_id => 2, team_id => 1})->expand->hashes->map( 56 | sub { 57 | is $_->{round}, 1, 'right round'; 58 | is $_->{status}, 104, 'right status'; 59 | is $_->{stdout}, '', 'right stdout'; 60 | my $result = $_->{result}; 61 | my $state = c(qw/get_2 get_1 put check/)->first(sub { defined $result->{$_}{exception} }); 62 | is $_->{result}{$state}{stderr}, '', 'right stderr'; 63 | is $_->{result}{$state}{stdout}, '', 'right stdout'; 64 | like $_->{result}{$state}{exception}, qr/timeout/i, 'right exception'; 65 | is $_->{result}{$state}{timeout}, 1, 'right timeout'; 66 | } 67 | ); 68 | 69 | # Service up1 70 | $db->select(runs => '*', {service_id => 3, team_id => 1})->expand->hashes->map( 71 | sub { 72 | is $_->{round}, 1, 'right round'; 73 | is $_->{status}, 101, 'right status'; 74 | is $_->{stdout}, '', 'right stdout'; 75 | for my $step (qw/check put get_1/) { 76 | is $_->{result}{$step}{stderr}, '', 'right stderr'; 77 | is $_->{result}{$step}{stdout}, '{"public_flag_id":"911","password":"sEcr3t"}', 'right stdout'; 78 | is $_->{result}{$step}{exception}, '', 'right exception'; 79 | is $_->{result}{$step}{timeout}, 0, 'right timeout'; 80 | } 81 | is keys %{$_->{result}{get_2}}, 0, 'right get_2'; 82 | } 83 | ); 84 | 85 | # Service up2 86 | $db->select(runs => '*', {service_id => 4, team_id => 1})->expand->hashes->map( 87 | sub { 88 | is $_->{round}, 1, 'right round'; 89 | is $_->{status}, 111, 'right status'; 90 | is $_->{stdout}, undef, 'right stdout'; 91 | is keys %{$_->{result}{check}}, 0, 'right check'; 92 | is keys %{$_->{result}{put}}, 0, 'right put'; 93 | is keys %{$_->{result}{get_1}}, 0, 'right get_1'; 94 | is keys %{$_->{result}{get_2}}, 0, 'right get_2'; 95 | is keys %{$_->{result}{get_2}}, 0, 'right get_2'; 96 | like $_->{result}{error}, qr/Service was disabled/i, 'right error'; 97 | } 98 | ); 99 | 100 | diag('SLA after #1'); 101 | is $db->query('select count(*) from sla')->array->[0], 12, 'right sla'; 102 | 103 | diag('FP after #1'); 104 | is $db->query('select count(*) from flag_points')->array->[0], 12, 'right fp'; 105 | 106 | # Flags (only for service up1) 107 | is $db->query('select count(*) from flags where ack = true')->array->[0], 3, 'right numbers of flags'; 108 | $db->query('select * from flags where ack = true')->hashes->map( 109 | sub { 110 | is $_->{round}, 1, 'right round'; 111 | is $_->{id}, '{"public_flag_id":"911","password":"sEcr3t"}', 'right id'; 112 | is $_->{public_id}, '911', 'right public id'; 113 | like $_->{data}, qr/TEAM\d{3}_[A-Z0-9]{32}/, 'right flag'; 114 | } 115 | ); 116 | 117 | # Enable up2 118 | $db->update('services', {ts_start => undef, ts_end => undef}, {name => 'up2'}); 119 | 120 | diag('New round #2'); 121 | $manager->start_round; 122 | is $manager->round, 2, 'right round'; 123 | $app->minion->perform_jobs({queues => ['default', 'checker']}); 124 | $app->model('score')->update; 125 | 126 | my $value = $app->model('scoreboard')->generate; 127 | if ($value->{round} == 1) { 128 | my $team1 = $value->{scoreboard}[0]; 129 | is $team1->{team_id}, 1, 'right scoreboard api'; 130 | is $team1->{round}, 1, 'right scoreboard api'; 131 | is $team1->{services}[3]{status}, 111, 'right scoreboard api'; 132 | 133 | is $value->{services}{1}{active}, 1, 'right scoreboard api'; 134 | is $value->{services}{1}{name}, 'down1', 'right scoreboard api'; 135 | 136 | is $value->{services}{4}{active}, 0, 'right scoreboard api'; 137 | is $value->{services}{4}{name}, 'up2', 'right scoreboard api'; 138 | } 139 | 140 | my ($data, $flag_data); 141 | my $flag_cb = sub { $data = $_[0] }; 142 | 143 | diag('Flags after #2'); 144 | $db->update('services', {ts_start => \"now() + interval '10 minutes'", ts_end => undef}, {name => 'up2'}); 145 | $flag_data = $db->select(flags => 'data', {team_id => 1, service_id => 4, ack => 'true'})->hash->{data}; 146 | $app->model('flag')->accept(2, $flag_data, $flag_cb); 147 | is $data->{ok}, 0, 'right status'; 148 | like $data->{error}, qr/service inactive/, 'right error'; 149 | $db->update('services', {ts_start => undef, ts_end => undef}, {name => 'up2'}); 150 | 151 | $app->model('flag')->accept(2, 'flag', $flag_cb); 152 | is $data->{ok}, 0, 'right status'; 153 | like $data->{error}, qr/invalid or own flag/, 'right error'; 154 | 155 | $flag_data = $db->select(flags => 'data', {team_id => 2, ack => 'true'})->hash->{data}; 156 | $app->model('flag')->accept(2, $flag_data, $flag_cb); 157 | is $data->{ok}, 0, 'right status'; 158 | like $data->{error}, qr/invalid or own flag/, 'right error'; 159 | 160 | $flag_data = $db->select(flags => 'data', {team_id => 1, ack => 'true'})->hash->{data}; 161 | $app->model('flag')->accept(2, $flag_data, $flag_cb); 162 | is $data->{ok}, 1, 'right status'; 163 | my $stolen_flag = $db->select(stolen_flags => undef, {team_id => 2})->hash; 164 | is $stolen_flag->{data}, $flag_data, 'right flag'; 165 | is $stolen_flag->{amount}, $app->config->{cs}{scoring}{start_flag_price}, 'right amount'; 166 | 167 | $app->model('flag')->accept(2, $flag_data, $flag_cb); 168 | is $data->{ok}, 0, 'right status'; 169 | like $data->{error}, qr/you already submitted this flag/, 'right error'; 170 | 171 | diag('SLA after #2'); 172 | is $db->query('select count(*) from sla')->array->[0], 24, 'right sla'; 173 | $data = $db->select(sla => '*', {team_id => 1, service_id => 1, round => 1})->hash; # down1 174 | is $data->{successed}, 0, 'right sla'; 175 | is $data->{failed}, 1, 'right sla'; 176 | $data = $db->select(sla => '*', {team_id => 1, service_id => 2, round => 1})->hash; # down2 177 | is $data->{successed}, 0, 'right sla'; 178 | is $data->{failed}, 1, 'right sla'; 179 | $data = $db->select(sla => '*', {team_id => 1, service_id => 3, round => 1})->hash; # up1 180 | is $data->{successed}, 1, 'right sla'; 181 | is $data->{failed}, 0, 'right sla'; 182 | $data = $db->select(sla => '*', {team_id => 1, service_id => 4, round => 1})->hash; # up2 183 | is $data->{successed}, 0, 'right sla'; 184 | is $data->{failed}, 0, 'right sla'; 185 | 186 | diag('FP after #2'); 187 | is $db->query('select count(*) from flag_points')->array->[0], 24, 'right fp'; 188 | $db->query('select * from flag_points where round = 1')->hashes->map(sub { is $_->{amount}, 0, 'right fp' }); 189 | 190 | diag('New round #3'); 191 | $manager->start_round; 192 | is $manager->round, 3, 'right round'; 193 | $app->minion->perform_jobs({queues => ['default', 'checker']}); 194 | $app->model('score')->update; 195 | 196 | diag('Flags after #3'); 197 | $flag_data = $db->select(flags => 'data', {team_id => 1, ack => 'true', round => 2})->hash->{data}; 198 | $app->model('flag')->accept(2, $flag_data, $flag_cb); 199 | is $data->{ok}, 1, 'right status'; 200 | $stolen_flag = $db->select(stolen_flags => undef, {team_id => 2, round => 3})->hash; 201 | is $stolen_flag->{data}, $flag_data, 'right flag'; 202 | ok $stolen_flag->{amount} > $app->config->{cs}{scoring}{start_flag_price}, 'right amount'; 203 | 204 | diag('SLA after #3'); 205 | is $db->query('select count(*) from sla')->array->[0], 36, 'right sla'; 206 | 207 | $data = $db->select(sla => '*', {team_id => 1, service_id => 1, round => 2})->hash; # down1 208 | is $data->{successed}, 0, 'right sla'; 209 | is $data->{failed}, 2, 'right sla'; 210 | $data = $db->select(sla => '*', {team_id => 1, service_id => 2, round => 2})->hash; # down2 211 | is $data->{successed}, 0, 'right sla'; 212 | is $data->{failed}, 2, 'right sla'; 213 | $data = $db->select(sla => '*', {team_id => 1, service_id => 3, round => 2})->hash; # up1 214 | is $data->{successed}, 2, 'right sla'; 215 | is $data->{failed}, 0, 'right sla'; 216 | $data = $db->select(sla => '*', {team_id => 1, service_id => 4, round => 2})->hash; # up2 217 | is $data->{successed}, 1, 'right sla'; 218 | is $data->{failed}, 0, 'right sla'; 219 | 220 | diag('FP after #3'); 221 | is $db->query('select count(*) from flag_points')->array->[0], 36, 'right fp'; 222 | 223 | diag('New round #4'); 224 | $manager->start_round; 225 | is $manager->round, 4, 'right round'; 226 | $app->minion->perform_jobs({queues => ['default', 'checker']}); 227 | $app->model('score')->update; 228 | 229 | # New team 230 | $app->commands->run( 231 | 'add_team', 232 | '{"name":"new team","network":"127.0.8.0/24","host":"127.0.1.8","token":"new_token","tags":["edu"]}' 233 | ); 234 | 235 | diag('New round #5'); 236 | $manager->start_round; 237 | is $manager->round, 5, 'right round'; 238 | $app->minion->perform_jobs({queues => ['default', 'checker']}); 239 | $app->model('score')->update; 240 | 241 | $app->model('score')->update(5); 242 | 243 | # API 244 | $t->get_ok('/api/info') 245 | ->json_has('/start') 246 | ->json_has('/end') 247 | ->json_has('/services') 248 | ->json_has('/teams') 249 | ->json_has('/teams/1/id') 250 | ->json_has('/teams/1/name') 251 | ->json_has('/teams/1/host') 252 | ->json_has('/teams/1/network') 253 | ->json_has('/teams/1/tags'); 254 | 255 | $t->get_ok('/teams') 256 | ->json_has('/1') 257 | ->json_is('/1/id', '1') 258 | ->json_is('/1/name', 'team1') 259 | ->json_is('/1/network', '127.0.1.0/24') 260 | ->json_has('/4') 261 | ->json_is('/4/id', '4') 262 | ->json_is('/4/name', 'new team'); 263 | 264 | $t->get_ok('/services') 265 | ->json_is('/1', 'down1') 266 | ->json_is('/2', 'down2') 267 | ->json_is('/3', 'up1') 268 | ->json_is('/4', 'up2'); 269 | 270 | $t->get_ok('/flag_ids?service_id=3' => {'X-Team-Token' => $team1->{token}}) 271 | ->json_is('/flag_id_description', $app->services->{3}{public_flag_description}) 272 | ->json_has('/flag_ids/2/flag_ids/0') 273 | ->json_has('/flag_ids/2/host') 274 | ->json_has('/flag_ids/3/flag_ids/0') 275 | ->json_has('/flag_ids/3/host'); 276 | 277 | $t->get_ok('/scoreboard.json') 278 | ->json_has('/round') 279 | ->json_has('/scoreboard') 280 | ->json_has('/scoreboard/0/d') 281 | ->json_has('/scoreboard/0/round') 282 | ->json_has('/scoreboard/0/host') 283 | ->json_has('/scoreboard/0/network') 284 | ->json_has('/scoreboard/0/team_id') 285 | ->json_has('/scoreboard/0/score') 286 | ->json_has('/scoreboard/0/old_score') 287 | ->json_has('/scoreboard/0/n') 288 | ->json_has('/scoreboard/0/name') 289 | ->json_has('/scoreboard/0/services') 290 | ->json_has('/scoreboard/0/old_services') 291 | ->json_has('/scoreboard/0/services/0/stdout') 292 | ->json_has('/scoreboard/0/services/0/id') 293 | ->json_has('/scoreboard/0/services/0/sflags') 294 | ->json_has('/scoreboard/0/services/0/flags') 295 | ->json_has('/scoreboard/0/services/0/sla') 296 | ->json_has('/scoreboard/0/services/0/fp') 297 | ->json_has('/scoreboard/0/services/0/status'); 298 | 299 | $t->get_ok('/history/scoreboard.json') 300 | ->json_has('/0/round') 301 | ->json_has('/0/scoreboard') 302 | ->json_has('/0/scoreboard/0/id') 303 | ->json_has('/0/scoreboard/0/score') 304 | ->json_has('/0/scoreboard/0/services') 305 | ->json_has('/0/scoreboard/0/services/0/sflags') 306 | ->json_has('/0/scoreboard/0/services/0/flags') 307 | ->json_has('/0/scoreboard/0/services/0/fp') 308 | ->json_has('/0/scoreboard/0/services/0/status'); 309 | 310 | $t->get_ok('/ctftime/scoreboard.json') 311 | ->json_has('/standings') 312 | ->json_has('/standings/0/pos') 313 | ->json_has('/standings/0/team') 314 | ->json_has('/standings/0/score'); 315 | 316 | $t->get_ok('/ctftime/fb.json'); 317 | 318 | $t->get_ok('/admin/info'); 319 | 320 | done_testing; 321 | -------------------------------------------------------------------------------- /public/css/bootstrap-theme.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.4 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default, 8 | .btn-primary, 9 | .btn-success, 10 | .btn-info, 11 | .btn-warning, 12 | .btn-danger { 13 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); 14 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 15 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 16 | } 17 | .btn-default:active, 18 | .btn-primary:active, 19 | .btn-success:active, 20 | .btn-info:active, 21 | .btn-warning:active, 22 | .btn-danger:active, 23 | .btn-default.active, 24 | .btn-primary.active, 25 | .btn-success.active, 26 | .btn-info.active, 27 | .btn-warning.active, 28 | .btn-danger.active { 29 | -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 30 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 31 | } 32 | .btn-default .badge, 33 | .btn-primary .badge, 34 | .btn-success .badge, 35 | .btn-info .badge, 36 | .btn-warning .badge, 37 | .btn-danger .badge { 38 | text-shadow: none; 39 | } 40 | .btn:active, 41 | .btn.active { 42 | background-image: none; 43 | } 44 | .btn-default { 45 | text-shadow: 0 1px 0 #fff; 46 | background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); 47 | background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%); 48 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0)); 49 | background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); 50 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); 51 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 52 | background-repeat: repeat-x; 53 | border-color: #dbdbdb; 54 | border-color: #ccc; 55 | } 56 | .btn-default:hover, 57 | .btn-default:focus { 58 | background-color: #e0e0e0; 59 | background-position: 0 -15px; 60 | } 61 | .btn-default:active, 62 | .btn-default.active { 63 | background-color: #e0e0e0; 64 | border-color: #dbdbdb; 65 | } 66 | .btn-default.disabled, 67 | .btn-default:disabled, 68 | .btn-default[disabled] { 69 | background-color: #e0e0e0; 70 | background-image: none; 71 | } 72 | .btn-primary { 73 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%); 74 | background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%); 75 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88)); 76 | background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%); 77 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0); 78 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 79 | background-repeat: repeat-x; 80 | border-color: #245580; 81 | } 82 | .btn-primary:hover, 83 | .btn-primary:focus { 84 | background-color: #265a88; 85 | background-position: 0 -15px; 86 | } 87 | .btn-primary:active, 88 | .btn-primary.active { 89 | background-color: #265a88; 90 | border-color: #245580; 91 | } 92 | .btn-primary.disabled, 93 | .btn-primary:disabled, 94 | .btn-primary[disabled] { 95 | background-color: #265a88; 96 | background-image: none; 97 | } 98 | .btn-success { 99 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); 100 | background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%); 101 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641)); 102 | background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); 103 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); 104 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 105 | background-repeat: repeat-x; 106 | border-color: #3e8f3e; 107 | } 108 | .btn-success:hover, 109 | .btn-success:focus { 110 | background-color: #419641; 111 | background-position: 0 -15px; 112 | } 113 | .btn-success:active, 114 | .btn-success.active { 115 | background-color: #419641; 116 | border-color: #3e8f3e; 117 | } 118 | .btn-success.disabled, 119 | .btn-success:disabled, 120 | .btn-success[disabled] { 121 | background-color: #419641; 122 | background-image: none; 123 | } 124 | .btn-info { 125 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 126 | background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 127 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2)); 128 | background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); 129 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); 130 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 131 | background-repeat: repeat-x; 132 | border-color: #28a4c9; 133 | } 134 | .btn-info:hover, 135 | .btn-info:focus { 136 | background-color: #2aabd2; 137 | background-position: 0 -15px; 138 | } 139 | .btn-info:active, 140 | .btn-info.active { 141 | background-color: #2aabd2; 142 | border-color: #28a4c9; 143 | } 144 | .btn-info.disabled, 145 | .btn-info:disabled, 146 | .btn-info[disabled] { 147 | background-color: #2aabd2; 148 | background-image: none; 149 | } 150 | .btn-warning { 151 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 152 | background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 153 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316)); 154 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); 155 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); 156 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 157 | background-repeat: repeat-x; 158 | border-color: #e38d13; 159 | } 160 | .btn-warning:hover, 161 | .btn-warning:focus { 162 | background-color: #eb9316; 163 | background-position: 0 -15px; 164 | } 165 | .btn-warning:active, 166 | .btn-warning.active { 167 | background-color: #eb9316; 168 | border-color: #e38d13; 169 | } 170 | .btn-warning.disabled, 171 | .btn-warning:disabled, 172 | .btn-warning[disabled] { 173 | background-color: #eb9316; 174 | background-image: none; 175 | } 176 | .btn-danger { 177 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 178 | background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 179 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a)); 180 | background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); 181 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); 182 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 183 | background-repeat: repeat-x; 184 | border-color: #b92c28; 185 | } 186 | .btn-danger:hover, 187 | .btn-danger:focus { 188 | background-color: #c12e2a; 189 | background-position: 0 -15px; 190 | } 191 | .btn-danger:active, 192 | .btn-danger.active { 193 | background-color: #c12e2a; 194 | border-color: #b92c28; 195 | } 196 | .btn-danger.disabled, 197 | .btn-danger:disabled, 198 | .btn-danger[disabled] { 199 | background-color: #c12e2a; 200 | background-image: none; 201 | } 202 | .thumbnail, 203 | .img-thumbnail { 204 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 205 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 206 | } 207 | .dropdown-menu > li > a:hover, 208 | .dropdown-menu > li > a:focus { 209 | background-color: #e8e8e8; 210 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 211 | background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 212 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); 213 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 214 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 215 | background-repeat: repeat-x; 216 | } 217 | .dropdown-menu > .active > a, 218 | .dropdown-menu > .active > a:hover, 219 | .dropdown-menu > .active > a:focus { 220 | background-color: #2e6da4; 221 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 222 | background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 223 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); 224 | background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); 225 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); 226 | background-repeat: repeat-x; 227 | } 228 | .navbar-default { 229 | background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); 230 | background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%); 231 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8)); 232 | background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); 233 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); 234 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 235 | background-repeat: repeat-x; 236 | border-radius: 4px; 237 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 238 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 239 | } 240 | .navbar-default .navbar-nav > .open > a, 241 | .navbar-default .navbar-nav > .active > a { 242 | background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); 243 | background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); 244 | background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2)); 245 | background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); 246 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); 247 | background-repeat: repeat-x; 248 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 249 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 250 | } 251 | .navbar-brand, 252 | .navbar-nav > li > a { 253 | text-shadow: 0 1px 0 rgba(255, 255, 255, .25); 254 | } 255 | .navbar-inverse { 256 | background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); 257 | background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%); 258 | background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222)); 259 | background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); 260 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); 261 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 262 | background-repeat: repeat-x; 263 | } 264 | .navbar-inverse .navbar-nav > .open > a, 265 | .navbar-inverse .navbar-nav > .active > a { 266 | background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%); 267 | background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%); 268 | background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f)); 269 | background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); 270 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); 271 | background-repeat: repeat-x; 272 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 273 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 274 | } 275 | .navbar-inverse .navbar-brand, 276 | .navbar-inverse .navbar-nav > li > a { 277 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); 278 | } 279 | .navbar-static-top, 280 | .navbar-fixed-top, 281 | .navbar-fixed-bottom { 282 | border-radius: 0; 283 | } 284 | @media (max-width: 767px) { 285 | .navbar .navbar-nav .open .dropdown-menu > .active > a, 286 | .navbar .navbar-nav .open .dropdown-menu > .active > a:hover, 287 | .navbar .navbar-nav .open .dropdown-menu > .active > a:focus { 288 | color: #fff; 289 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 290 | background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 291 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); 292 | background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); 293 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); 294 | background-repeat: repeat-x; 295 | } 296 | } 297 | .alert { 298 | text-shadow: 0 1px 0 rgba(255, 255, 255, .2); 299 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 300 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 301 | } 302 | .alert-success { 303 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 304 | background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 305 | background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc)); 306 | background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); 307 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); 308 | background-repeat: repeat-x; 309 | border-color: #b2dba1; 310 | } 311 | .alert-info { 312 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 313 | background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 314 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0)); 315 | background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); 316 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); 317 | background-repeat: repeat-x; 318 | border-color: #9acfea; 319 | } 320 | .alert-warning { 321 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 322 | background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 323 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0)); 324 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); 325 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); 326 | background-repeat: repeat-x; 327 | border-color: #f5e79e; 328 | } 329 | .alert-danger { 330 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 331 | background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 332 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3)); 333 | background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); 334 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); 335 | background-repeat: repeat-x; 336 | border-color: #dca7a7; 337 | } 338 | .progress { 339 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 340 | background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 341 | background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5)); 342 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); 343 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); 344 | background-repeat: repeat-x; 345 | } 346 | .progress-bar { 347 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%); 348 | background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%); 349 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090)); 350 | background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%); 351 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0); 352 | background-repeat: repeat-x; 353 | } 354 | .progress-bar-success { 355 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); 356 | background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%); 357 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44)); 358 | background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); 359 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); 360 | background-repeat: repeat-x; 361 | } 362 | .progress-bar-info { 363 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 364 | background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 365 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5)); 366 | background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); 367 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); 368 | background-repeat: repeat-x; 369 | } 370 | .progress-bar-warning { 371 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 372 | background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 373 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f)); 374 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); 375 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); 376 | background-repeat: repeat-x; 377 | } 378 | .progress-bar-danger { 379 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); 380 | background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%); 381 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c)); 382 | background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); 383 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); 384 | background-repeat: repeat-x; 385 | } 386 | .progress-bar-striped { 387 | background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); 388 | background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); 389 | background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); 390 | } 391 | .list-group { 392 | border-radius: 4px; 393 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 394 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 395 | } 396 | .list-group-item.active, 397 | .list-group-item.active:hover, 398 | .list-group-item.active:focus { 399 | text-shadow: 0 -1px 0 #286090; 400 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%); 401 | background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%); 402 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a)); 403 | background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%); 404 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0); 405 | background-repeat: repeat-x; 406 | border-color: #2b669a; 407 | } 408 | .list-group-item.active .badge, 409 | .list-group-item.active:hover .badge, 410 | .list-group-item.active:focus .badge { 411 | text-shadow: none; 412 | } 413 | .panel { 414 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 415 | box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 416 | } 417 | .panel-default > .panel-heading { 418 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 419 | background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 420 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); 421 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 422 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 423 | background-repeat: repeat-x; 424 | } 425 | .panel-primary > .panel-heading { 426 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 427 | background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 428 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); 429 | background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); 430 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); 431 | background-repeat: repeat-x; 432 | } 433 | .panel-success > .panel-heading { 434 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 435 | background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 436 | background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6)); 437 | background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); 438 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); 439 | background-repeat: repeat-x; 440 | } 441 | .panel-info > .panel-heading { 442 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 443 | background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 444 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3)); 445 | background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); 446 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); 447 | background-repeat: repeat-x; 448 | } 449 | .panel-warning > .panel-heading { 450 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 451 | background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 452 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc)); 453 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); 454 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); 455 | background-repeat: repeat-x; 456 | } 457 | .panel-danger > .panel-heading { 458 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 459 | background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 460 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc)); 461 | background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); 462 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); 463 | background-repeat: repeat-x; 464 | } 465 | .well { 466 | background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 467 | background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 468 | background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5)); 469 | background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); 470 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); 471 | background-repeat: repeat-x; 472 | border-color: #dcdcdc; 473 | -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 474 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 475 | } 476 | /*# sourceMappingURL=bootstrap-theme.css.map */ 477 | -------------------------------------------------------------------------------- /public/css/bootstrap-theme.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","bootstrap-theme.css","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":"AAcA;;;;;;EAME,0CAAA;ECgDA,6FAAA;EACQ,qFAAA;EC5DT;AFgBC;;;;;;;;;;;;EC2CA,0DAAA;EACQ,kDAAA;EC7CT;AFVD;;;;;;EAiBI,mBAAA;EECH;AFiCC;;EAEE,wBAAA;EE/BH;AFoCD;EGnDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EAgC2C,2BAAA;EAA2B,oBAAA;EEzBvE;AFLC;;EAEE,2BAAA;EACA,8BAAA;EEOH;AFJC;;EAEE,2BAAA;EACA,uBAAA;EEMH;AFHC;;;EAGE,2BAAA;EACA,wBAAA;EEKH;AFUD;EGpDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEgCD;AF9BC;;EAEE,2BAAA;EACA,8BAAA;EEgCH;AF7BC;;EAEE,2BAAA;EACA,uBAAA;EE+BH;AF5BC;;;EAGE,2BAAA;EACA,wBAAA;EE8BH;AFdD;EGrDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEyDD;AFvDC;;EAEE,2BAAA;EACA,8BAAA;EEyDH;AFtDC;;EAEE,2BAAA;EACA,uBAAA;EEwDH;AFrDC;;;EAGE,2BAAA;EACA,wBAAA;EEuDH;AFtCD;EGtDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEkFD;AFhFC;;EAEE,2BAAA;EACA,8BAAA;EEkFH;AF/EC;;EAEE,2BAAA;EACA,uBAAA;EEiFH;AF9EC;;;EAGE,2BAAA;EACA,wBAAA;EEgFH;AF9DD;EGvDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EE2GD;AFzGC;;EAEE,2BAAA;EACA,8BAAA;EE2GH;AFxGC;;EAEE,2BAAA;EACA,uBAAA;EE0GH;AFvGC;;;EAGE,2BAAA;EACA,wBAAA;EEyGH;AFtFD;EGxDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEoID;AFlIC;;EAEE,2BAAA;EACA,8BAAA;EEoIH;AFjIC;;EAEE,2BAAA;EACA,uBAAA;EEmIH;AFhIC;;;EAGE,2BAAA;EACA,wBAAA;EEkIH;AFxGD;;EChBE,oDAAA;EACQ,4CAAA;EC4HT;AFnGD;;EGzEI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EHwEF,2BAAA;EEyGD;AFvGD;;;EG9EI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH8EF,2BAAA;EE6GD;AFpGD;EG3FI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ECnBF,qEAAA;EJ6GA,oBAAA;EC/CA,6FAAA;EACQ,qFAAA;EC0JT;AF/GD;;EG3FI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EF2CF,0DAAA;EACQ,kDAAA;ECoKT;AF5GD;;EAEE,gDAAA;EE8GD;AF1GD;EG9GI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ECnBF,qEAAA;EF+OD;AFlHD;;EG9GI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EF2CF,yDAAA;EACQ,iDAAA;EC0LT;AF5HD;;EAYI,2CAAA;EEoHH;AF/GD;;;EAGE,kBAAA;EEiHD;AF5FD;EAfI;;;IAGE,aAAA;IG3IF,0EAAA;IACA,qEAAA;IACA,+FAAA;IAAA,wEAAA;IACA,6BAAA;IACA,wHAAA;ID0PD;EACF;AFxGD;EACE,+CAAA;ECzGA,4FAAA;EACQ,oFAAA;ECoNT;AFhGD;EGpKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EE4GD;AFvGD;EGrKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EEoHD;AF9GD;EGtKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EE4HD;AFrHD;EGvKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EEoID;AFrHD;EG/KI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDuSH;AFlHD;EGzLI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED8SH;AFxHD;EG1LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDqTH;AF9HD;EG3LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED4TH;AFpID;EG5LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDmUH;AF1ID;EG7LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED0UH;AF7ID;EGhKI,+MAAA;EACA,0MAAA;EACA,uMAAA;EDgTH;AFzID;EACE,oBAAA;EC5JA,oDAAA;EACQ,4CAAA;ECwST;AF1ID;;;EAGE,+BAAA;EGjNE,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH+MF,uBAAA;EEgJD;AFrJD;;;EAQI,mBAAA;EEkJH;AFxID;ECjLE,mDAAA;EACQ,2CAAA;EC4TT;AFlID;EG1OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED+WH;AFxID;EG3OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDsXH;AF9ID;EG5OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED6XH;AFpJD;EG7OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDoYH;AF1JD;EG9OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED2YH;AFhKD;EG/OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDkZH;AFhKD;EGtPI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EHoPF,uBAAA;ECzMA,2FAAA;EACQ,mFAAA;ECgXT","file":"bootstrap-theme.css","sourcesContent":["\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &:disabled,\n &[disabled] {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They will be removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility){\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n",".btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.btn-default:active,\n.btn-primary:active,\n.btn-success:active,\n.btn-info:active,\n.btn-warning:active,\n.btn-danger:active,\n.btn-default.active,\n.btn-primary.active,\n.btn-success.active,\n.btn-info.active,\n.btn-warning.active,\n.btn-danger.active {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-default .badge,\n.btn-primary .badge,\n.btn-success .badge,\n.btn-info .badge,\n.btn-warning .badge,\n.btn-danger .badge {\n text-shadow: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n}\n.btn-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #dbdbdb;\n text-shadow: 0 1px 0 #fff;\n border-color: #ccc;\n}\n.btn-default:hover,\n.btn-default:focus {\n background-color: #e0e0e0;\n background-position: 0 -15px;\n}\n.btn-default:active,\n.btn-default.active {\n background-color: #e0e0e0;\n border-color: #dbdbdb;\n}\n.btn-default.disabled,\n.btn-default:disabled,\n.btn-default[disabled] {\n background-color: #e0e0e0;\n background-image: none;\n}\n.btn-primary {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #245580;\n}\n.btn-primary:hover,\n.btn-primary:focus {\n background-color: #265a88;\n background-position: 0 -15px;\n}\n.btn-primary:active,\n.btn-primary.active {\n background-color: #265a88;\n border-color: #245580;\n}\n.btn-primary.disabled,\n.btn-primary:disabled,\n.btn-primary[disabled] {\n background-color: #265a88;\n background-image: none;\n}\n.btn-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #3e8f3e;\n}\n.btn-success:hover,\n.btn-success:focus {\n background-color: #419641;\n background-position: 0 -15px;\n}\n.btn-success:active,\n.btn-success.active {\n background-color: #419641;\n border-color: #3e8f3e;\n}\n.btn-success.disabled,\n.btn-success:disabled,\n.btn-success[disabled] {\n background-color: #419641;\n background-image: none;\n}\n.btn-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #28a4c9;\n}\n.btn-info:hover,\n.btn-info:focus {\n background-color: #2aabd2;\n background-position: 0 -15px;\n}\n.btn-info:active,\n.btn-info.active {\n background-color: #2aabd2;\n border-color: #28a4c9;\n}\n.btn-info.disabled,\n.btn-info:disabled,\n.btn-info[disabled] {\n background-color: #2aabd2;\n background-image: none;\n}\n.btn-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #e38d13;\n}\n.btn-warning:hover,\n.btn-warning:focus {\n background-color: #eb9316;\n background-position: 0 -15px;\n}\n.btn-warning:active,\n.btn-warning.active {\n background-color: #eb9316;\n border-color: #e38d13;\n}\n.btn-warning.disabled,\n.btn-warning:disabled,\n.btn-warning[disabled] {\n background-color: #eb9316;\n background-image: none;\n}\n.btn-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #b92c28;\n}\n.btn-danger:hover,\n.btn-danger:focus {\n background-color: #c12e2a;\n background-position: 0 -15px;\n}\n.btn-danger:active,\n.btn-danger.active {\n background-color: #c12e2a;\n border-color: #b92c28;\n}\n.btn-danger.disabled,\n.btn-danger:disabled,\n.btn-danger[disabled] {\n background-color: #c12e2a;\n background-image: none;\n}\n.thumbnail,\n.img-thumbnail {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n background-color: #e8e8e8;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n background-color: #2e6da4;\n}\n.navbar-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);\n}\n.navbar-inverse {\n background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: -o-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n}\n.navbar-inverse .navbar-brand,\n.navbar-inverse .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n@media (max-width: 767px) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n }\n}\n.alert {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.alert-success {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);\n border-color: #b2dba1;\n}\n.alert-info {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);\n border-color: #9acfea;\n}\n.alert-warning {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);\n border-color: #f5e79e;\n}\n.alert-danger {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);\n border-color: #dca7a7;\n}\n.progress {\n background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);\n}\n.progress-bar {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);\n}\n.progress-bar-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);\n}\n.progress-bar-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);\n}\n.progress-bar-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);\n}\n.progress-bar-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);\n}\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.list-group {\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 #286090;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);\n border-color: #2b669a;\n}\n.list-group-item.active .badge,\n.list-group-item.active:hover .badge,\n.list-group-item.active:focus .badge {\n text-shadow: none;\n}\n.panel {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.panel-default > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n}\n.panel-primary > .panel-heading {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n}\n.panel-success > .panel-heading {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);\n}\n.panel-info > .panel-heading {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);\n}\n.panel-warning > .panel-heading {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);\n}\n.panel-danger > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);\n}\n.well {\n background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);\n border-color: #dcdcdc;\n -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n}\n/*# sourceMappingURL=bootstrap-theme.css.map */","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} --------------------------------------------------------------------------------